Skip to content

Instantly share code, notes, and snippets.

@Charl13
Last active March 5, 2025 13:19
Show Gist options
  • Save Charl13/989cbc5541344b1183dd945e01f9bf73 to your computer and use it in GitHub Desktop.
Save Charl13/989cbc5541344b1183dd945e01f9bf73 to your computer and use it in GitHub Desktop.
API Platform ingegration

API Platform integration

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]

1. Configuration

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).

Workflow

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.

2. API Domain

The GraphQL schema is generated from Data Transfer Objects (DTO's). Their names, properties and relationschips are translated into GraphQL queries, fields and types.

Data transfer object
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 = [];
}
GraphQL Type created from DTO
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 between entities and data transfer objects

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).

AutoMapper+ data mapping
%%{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")
Loading

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).

Mapping to Doctrine Collection

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);
}

3. Normalization and serialization

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.

Symfony Serializer's normalization
%%{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")
Loading
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.

(De)normalizing relationships

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.

Normalized data
[
	'company' => '/api/companies/1',

	'safetyDiplomas' => [
		'/api/safety-diplomas/1',
		'/api/safety-diplomas/2'
	],
];
Data transfer object
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;
}
Doctrine entity
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).

Loading deeper to-one entity relationships

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();
}

4. Input validation policies

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.

Error messages

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();
    }
}        

5. To do

  • Configure API resources using PHP attributes. [1]
  • Move data mapping to state providers/processors. [1]
  • Upgrade to version 4.x.
  • Add fetch-joins for deep relationships. [1]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment