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

700 lines
23 KiB
Markdown

# API
Chill provides a basic framework to build REST api.
###### Basic configuration
## Configure a route
Follow those steps to build a REST api:
1. Create your model;
2. Configure the API;
You can also:
* hook into the controller to customize some steps;
* add more routes and steps
Useful links:
* [How to use annotation to configure serialization ](https://symfony.com/doc/current/serializer.html)
* [How to create your custom normalizer ](https://symfony.com/doc/current/serializer/custom_normalizer.html)
## Autoloading the routes
Ensure that those lines are present in your file `app/config/routing.yml`:
```yaml
chill_cruds:
resource: 'chill_main_crud_route_loader:load'
type: service
```
## Create your model
Create your model in the usual way:
```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: [api_full_configuration](api_full_configuration.md)):
```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
```
If you are working on a shared bundle (aka "The chill bundles"), you should define your configuration inside the class `ChillXXXXBundleExtension`, using the "prependConfig" feature:
```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 `_index` and `_entity` action
The `_index` and `_entity` action are default actions:
* they will call a specific method in the default controller;
* they will generate defined routes:
Index:
Name: `chill_api_single_accompanying_period_origin__index`
Path: `/api/1.0/person/accompanying-period/origin.{_format}`
Entity:
Name: `chill_api_single_accompanying_period_origin__entity`
Path: `/api/1.0/person/accompanying-period/origin/{id}.{_format}`
###### Role
By default, the key `base_role` is used to check ACL. Take care of creating the `Voter` required to take that into account.
For index action, the role will be called with `NULL` as `$subject`. The retrieved entity will be the subject for single queries.
You can also define a role for each method. In this case, this role is used for the given method, and, if any, the base role is taken into account.
```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 `Chill\MainBundle\CRUD\Controller\ApiController`.
```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:
```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:
```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 custom participation:
participation:
methods:
POST: true
DELETE: true
GET: false
HEAD: false
PUT: false
roles:
POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
GET: null
HEAD: null
PUT: null
single-collection: single
```
The key `single-collection` with value `single` will add a `/{id}/ + "action name"` (in this example, `/{id}/participation`) into the path, after the base path. If the value is `collection`, no id will be set, but the action name will be append to the path.
Then, create the corresponding action into your controller:
```php
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Chill\PersonBundle\Privacy\AccompanyingPeriodPrivacyEvent;
use Chill\PersonBundle\Entity\Person;
class AccompanyingCourseApiController extends ApiController
{
protected EventDispatcherInterface $eventDispatcher;
protected ValidatorInterface $validator;
public function __construct(EventDispatcherInterface $eventDispatcher, $validator)
{
$this->eventDispatcher = $eventDispatcher;
$this->validator = $validator;
}
public function participationApi($id, Request $request, $_format)
{
/** @var AccompanyingPeriod $accompanyingPeriod */
$accompanyingPeriod = $this->getEntity('participation', $id, $request);
$person = $this->getSerializer()
->deserialize($request->getContent(), Person::class, $_format, []);
if (NULL === $person) {
throw new BadRequestException('person id not found');
}
$this->onPostCheckACL('participation', $request, $accompanyingPeriod, $_format);
switch ($request->getMethod()) {
case Request::METHOD_POST:
$participation = $accompanyingPeriod->addPerson($person);
break;
case Request::METHOD_DELETE:
$participation = $accompanyingPeriod->removePerson($person);
break;
default:
throw new BadRequestException("This method is not supported");
}
$errors = $this->validator->validate($accompanyingPeriod);
if ($errors->count() > 0) {
// only format accepted
return $this->json($errors);
}
$this->getDoctrine()->getManager()->flush();
return $this->json($participation);
}
}
```
###### Managing association
## ManyToOne association
In ManyToOne association, you can add associated entities using the `PATCH` request. By default, the serializer deserialize entities only with their id and discriminator type, if any.
Example:
```
curl -X 'PATCH' \
'http://localhost:8001/api/1.0/person/accompanying-course/2668.json' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
# see the data sent to the server: \
-d '{
"type": "accompanying_period",
"id": 2668,
"origin": { "id": 11 }
}'
```
## ManyToMany associations
In OneToMany association, you can easily create route for adding and removing entities, using `POST` and `DELETE` requests.
Prepare your entity, creating the methods `addYourEntity` and `removeYourEntity`:
```php
namespace Chill\PersonBundle\Entity;
use Chill\MainBundle\Entity\Scope;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* AccompanyingPeriod Class
*
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period")
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period"=AccompanyingPeriod::class
* })
*/
class AccompanyingPeriod
{
/**
* @var Collection
* @ORM\ManyToMany(
* targetEntity=Scope::class,
* cascade={}
* )
* @Groups({"read"})
*/
private $scopes;
public function addScope(Scope $scope): self
{
$this->scopes[] = $scope;
return $this;
}
public function removeScope(Scope $scope): void
{
$this->scopes->removeElement($scope);
}
```
Create your route into the configuration:
```yaml
# config/packages/chill_main.yaml`
chill_main:
apis:
-
class: Chill\PersonBundle\Entity\AccompanyingPeriod
name: accompanying_course
base_path: /api/1.0/person/accompanying-course
controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController
actions:
scope:
methods:
POST: true
DELETE: true
GET: false
HEAD: false
PUT: false
PATCH: false
roles:
POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
GET: null
HEAD: null
PUT: null
PATCH: null
controller_action: null
path: null
single-collection: single
```
This will create a new route, which will accept two methods: DELETE and POST:
+--------------+---------------------------------------------------------------------------------------+
| Property | Value |
| Route Name | chill_api_single_accompanying_course_scope |
| Path | /api/1.0/person/accompanying-course/{id}/scope.{_format} |
| Path Regex | {^/api/1\.0/person/accompanying\-course/(?P<id>[^/]++)/scope\.(?P<_format>[^/]++)$}sD |
| Host | ANY |
| Host Regex | |
| Scheme | ANY |
| Method | POST|DELETE |
| Requirements | {id}: \d+ |
| Class | Symfony\Component\Routing\Route |
| Defaults | _controller: csapi_accompanying_course_controller:scopeApi |
| Options | compiler_class: Symfony\Component\Routing\RouteCompiler |
Then, create the controller action. Call the method:
```php
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Chill\MainBundle\Entity\Scope;
class MyController extends ApiController
{
public function scopeApi($id, Request $request, string $_format): Response
{
return $this->addRemoveSomething('scope', $id, $request, $_format, 'scope', Scope::class, [ 'groups' => [ 'read' ] ]);
}
}
```
This will allow adding a scope by his id and deleting them.
Curl requests:
```
# add a scope with id 5
curl -X 'POST' \
'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"type": "scope",
"id": 5
}'
# remove a scope with id 5
curl -X 'DELETE' \
'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"id": 5,
"type": "scope"
}'
```
## Deserializing an association where multiple types are allowed
Sometimes, multiple types are allowed as association to one entity:
```php
namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\Mapping as ORM;
class Resource
{
/**
* @ORM\ManyToOne(targetEntity=ThirdParty::class)
* @ORM\JoinColumn(nullable=true)
*/
private $thirdParty;
/**
* @ORM\ManyToOne(targetEntity=Person::class)
* @ORM\JoinColumn(nullable=true)
*/
private $person;
/**
*
* @param $resource Person|ThirdParty
*/
public function setResource($resource): self
{
// ...
}
/**
* @return ThirdParty|Person
* @Groups({"read", "write"})
*/
public function getResource()
{
return $this->person ?? $this->thirdParty;
}
}
```
This is not well taken into account by the Symfony serializer natively.
You must, then, create your own CustomNormalizer. You can help yourself using this:
```php
namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Chill\PersonBundle\Repository\AccompanyingPeriod\ResourceRepository;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
use Symfony\Component\Serializer\Exception;
use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer;
class AccompanyingPeriodResourceNormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
use ObjectToPopulateTrait;
public function __construct(ResourceRepository $repository)
{
$this->repository = $repository;
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
// .. snipped for brevity
if ($resource === NULL) {
$resource = new Resource();
}
if (\array_key_exists('resource', $data)) {
$res = $this->denormalizer->denormalize(
$data['resource'],
// call for a "multiple type"
DiscriminatedObjectDenormalizer::TYPE,
$format,
// into the context, we add the list of allowed types:
[
DiscriminatedObjectDenormalizer::ALLOWED_TYPES =>
[
Person::class, ThirdParty::class
]
]
);
$resource->setResource($res);
}
return $resource;
}
public function supportsDenormalization($data, string $type, string $format = null)
{
return $type === Resource::class;
}
}
```
###### Serialization for collection
A specific model has been defined for returning collection:
```
{
"count": 49,
"results": [
],
"pagination": {
"more": true,
"next": "/api/1.0/search.json&q=xxxx......&page=2",
"previous": null,
"first": 0,
"items_per_page": 1
}
}
```
Where this is relevant, this model should be re-used in custom controller actions.
In custom actions, this can be achieved quickly by assembling results into a `Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using `Paginator` (see [Pagination ](pagination-ref.md)).
```php
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Chill\MainBundle\Pagination\PaginatorInterface;
class MyController extends AbstractController
{
protected function serializeCollection(PaginatorInterface $paginator, $entities): Response
{
$model = new Collection($entities, $paginator);
return $this->json($model, Response::HTTP_OK, [], $context);
}
}
```
###### Full configuration example
```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
```