This page aims to explain the integration of API Platform and mostly in particular the modifications made to the features of API Platform.
Integrated at: version 2.6 Current version: version 2.7 Configuration type: Annotation, YAML Last updated at: 2024-03-05 Last updated by: Charlie Vieillard, [email protected] Originally authored by: Charlie Vieillard, [email protected]
API resources are registered through configuration. This was orignally done using annotations. With the upgrade of API platform from version 2.6 to version 2.7 (c06aef9fe
) registering resources through annotations became deprecated and replaced by PHP attributes. New features introduced in version 2.7 can not be configured using annotations and since the current version of PHP does not support PHP attributes some resources (User
, UserGroup
, AuditTrail
, Division
, Project
etc.) are configured in YAML. The YAML configuration is located at config/api_platform/resources.yaml
. After upgrading PHP to a version that supports PHP attributes all configuration should be re-written (see To do section).
API Platform version 2.7 leverages a workflow (or see the workflow of API Platform's most recent version). This work flow consists of steps like serialization, normalization, validation, security, storing and retrieving data.
The GraphQL schema is generated from Data Transfer Objects (DTO's). Their names, properties and relationschips are translated into GraphQL queries, fields and types.
namespace App\ApiPlatform\Dto\Output
use App\Entity\Customer\Company;
final class Employee
{
public int $id;
public string $firstName;
public string $lastName;
public Company $company;
public array $safetyDiploma = [];
}
type Employee implements Node {
id: ID!
firstName: String!
lastName: String!
company: Company!
safetyDiplomas(
): SafetyDiplomaCursorConnection
}
These data transfer objects hold data that is mapped from API resources (entities) which will be used to normalize/serialize to create raw data. API Platform uses IRI's as Global Object Identifiers which means the GraphQL type ID!
should be an IRI.
Mapping entities to data transfer objects and visa versa in version 2.6 of API Platform is done using data transformers which implement API Platform's DataTransformerInterface
. Currently there are two data transformers, one for input and one for output. They use AutoMapper+ to map data between entities and data transfer objects. Only the input data transformer handles validation (see Input validation).
%%{init: {'flowchart': {'diagramPadding': 30, 'padding': 80, 'nodeSpacing': 100}}}%%
flowchart LR
classDef node stroke:black,font-size:14pt
entity <-- Mapping ----> dto
entity("Entity")
dto("Data transfer
object")
Mapping configuration is done using implementations of the AutoMapperConfiguratorInterface
of AutoMapper+. A configurator is used per API resource (entitiy) to register and configure mappings.
// AutoMapper+ configurator for mapping from and to the employee entity.
class EmployeeConfigurator implements AutoMapperConfiguratorInterface
{
public function configure(AutoMapperConfigInterface $config): void
{
// Mapping from entity to data transfer object.
$config->registerMapping(Employee::class, Output\Employee::class);
// Mapping from data transfer object to entity.
$config->registerMapping(Input\CreateEmployee::class, Employee::class);
}
}
Note: In API Platform 2.6 data transformers are used just before normalization in the
AbstractItemNormalizer
. In API Platform 2.7 data transformers got deprecated. Instead this should be done in state providers and processors. (see To do). One way to achieve this is to decorate API Platform's default state provider and processor to use AutoMapper+. This would also mean that it should also handle relationships and Doctrine's proxy objects (see Loading deeper to-one-entity-relationships).
Fields holding a to-many relationship are typed as array
in data transfer objects, and typed as Doctrine's Collection
interface in entities (see (De)normalizing relationships to read more about normalization of these fields). Because AutoMapper+ does not provide a way to specify a strategy for a specific type (e.g. convert array
to Collection
) the DefaultMappingOperation
of AutoMapper+ is extended by the ArrayToCollectionSupportMappingOperation
to do the conversion.
public function mapProperty(
string $propertyName,
$source,
$destination
): void {
// Lines of code removed for sake of simplicity.
if (! is_a($type, Collection::class, true)) {
parent::mapProperty($propertyName, $source, $destination);
return;
}
$collection = new ArrayCollection($value);
$this->setDestinationValue($destination, $propertyName, $collection);
}
Normalization and serialization is done using the Symfony Serializer. This uses the ItemNormalizer
provided by API Platform. An important task of this normalizer is that it converts identifiers to API resources (entities) and visa versa.
%%{init: {'flowchart': {'diagramPadding': 30, 'padding': 80, 'nodeSpacing': 100}}}%%
flowchart LR
classDef node stroke:black,font-size:14pt
dto <-- "Normalization/
denormalization" ----> array
dto("Data transfer
object")
array("Array")
Keyword | Description |
---|---|
Mapping | Read and write data between DTO and entity. |
Normalization | Transform structured data to serializable/textual data. Normalization is from array to DTO. |
Denormalization | Transform serializable/textual data to structured data. Denormalization is from DTO to array. |
Normalizing relationships is the process of converting API resources (entities) to their identifiers. The following code shows an example of a to-one and a to-many relationship in a normalized form (Normalized data), de-normalized form (Data transfer object) and API resource (Doctrine entity after mapping). The data transfer object's equally named property in the API resource will ensure that API Platform's ItemNormalizer
normalizes the identifier to the correct data.
[
'company' => '/api/companies/1',
'safetyDiplomas' => [
'/api/safety-diplomas/1',
'/api/safety-diplomas/2'
],
];
namespace App\ApiPlatform\Dto\Output
use App\Entity\Customer\Company;
use App\Entity\Customer\SafetyDiploma;
final class Employee
{
public Company $company;
/** @var SafetyDiploma[] */
public array $safetyDiplomas;
}
namespace App\Entity\Customer;
use Doctrine\Common\Collections\Collection;
class Employee
{
/**
* @ORM\ManyToOne(
* targetEntity=Company::class,
* inversedBy="employees",
* )
*/
protected Company $company;
/**
* @var Collection<int, SafetyDiploma>
*
* @ORM\OneToMany(
* targetEntity="SafetyDiploma",
* mappedBy="employee",
* )
*/
protected Collection $safetyDiplomas;
}
Note: To-many relationship fields are typed as array because the
AbstractItemNormalizer
of API Platform does not support de-normalizing to other iterable types. AutoMapper+ is extended with support to map array's to Doctrine collections (see Mapping to Doctrine Collection).
In API Platform the Doctrine ORM EagerLoadingExentsion
adds fetch-joins to the SQL query of direct associations of the root query type. Deeper nested to-one relations (relation of relation) are not handled by this extension. This means that without explicitly using Doctrine configuration to set every to-one relationship as eager loaded, deeper nested to-one relations are fetched as uninitialized Doctrine proxy objects. Normalizing these objects will result in null
values.
{
employeeAgreement(id: "/api/employee-agreements/1") {
employee {
company {
country # <-- This will be null
}
}
}
}
This is not the case for to-many relationships as the PersistentCollection
are initialized with entities when the collection is accessed for the first time. To avoid deeper nested relationship falsely showing null
values the decoration of API Platform's ItemNormalizer
initializes proxy objects. This can be improved at extension level, replacing the Doctrine ORM EagerLoadingExtension
or adding a new extension for deeper nested to-one relationships (see To do).
if ($object instanceof Proxy && ! $object->__isInitialized()) {
$object->__load();
}
Validation is a part of API Platform's workflow. By default validation rules are configured in API resources (entities) using annotations (up to version 2.6) or PHP attributes. In the current implementation these rules are separated from the API resources and maintained in validation policies.
namespace App\ApiPlatform\Validation\Policies;
use App\ApiPlatform\Dto\Input;
use App\ApiPlatform\Validation\Constraints;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Mapping\ClassMetadata;
class CreateEmployeePolicy implements PolicyInterface
{
public function register(
ClassMetadata $classMetadata,
$input
): void {
$classMetadata->addPropertyConstraint('firstName', new NotBlank())
->addPropertyConstraint('lastName', new NotBlank())
->addConstraint(new Constraints\CreateEmployee());
}
public function getTarget(): string
{
// What kind of value type this policy should be used for.
return Input\CreateEmployee::class;
}
}
Simple validations use property constraints. When more complex validation is required a class constraint is used. The convention is to have one class constraint per target. Following the previous example this would mean the validator used for Constraints\CreateEmployee
houses all custom validation for values of type Input\CreateEmployee
.
namespace App\ApiPlatform\Validation\Constraints;
use App\ApiPlatform\Dto\Input;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class CreateEmployeeValidator extends ConstraintValidator
{
public function validate($input, Constraint $constraint): void
{
$this->employeeIsUniqueForCompany($input);
}
protected function employeeIsUniqueForCompany(
Input\CreateOrUpdateEmployee $input
): void {
// Custom validation.
}
}
The implementation that makes API Platform use these validation policies consist of a few parts.
Class | Responsibility |
---|---|
ValidatorFactory |
Uses the Symfony validation builder to create the validator with MetadataFactory and ConstraintValidatorFactory which is set as default for API Platform's validation. |
MetadataFactory |
Uses the validation policies to create ClassMetadata containing the validation constraints. |
ConstraintValidatorFactory |
A factory that uses the service container to construct validators related to the validation constraints. This enables dependency injection into validators. |
AddValidationPoliciesToMetadataFactoryPass |
Injects validation policies implementing the PolicyInterface into the MetadataFactory . |
API Platform uses the default error format of graphql-php for validation errors.
{
"errors": [
{
"debugMessage": "personIdentifier: Dit nummer is reeds in gebruik door een werknemer die werkt voor Bakkersconcurrent.",
"message": "personIdentifier: Dit nummer is reeds in gebruik door een werknemer die werkt voor Bakkersconcurrent.",
"extensions": {
"category": "user",
"status": 422,
"violations": [
{
"path": "personIdentifier",
"message": "Dit nummer is reeds in gebruik door een werknemer die werkt voor Bakkersconcurrent."
}
]
},
"locations": [],
"path": ["createOrUpdateEmployee"],
"trace": [],
}
]
}
The path of each violation tells the client(s) which part of the input the validation error relates to. This can be used to show the validation error at the correct field(s). When using property constraints the path is automatically set, on class constraints this should be done manually.
class CreateEmployeeValidator extends ConstraintValidator
{
public function validate($input, Constraint $constraint): void
{
// Validation
$this->context->buildViolation($message)
->atPath('personIdentifier')
->setTranslationDomain('error_handling')
->addViolation();
}
}