@SmaineDev
@SmaineDev
Team API
Team ECOMMERCE
@SmaineDev
Legacy
Database 👴
New
Database 👶
Double
writing 🖊️
WRITE
READ
WRITE
@SmaineDev
Legacy
Database 👴
TEAM API
WRITE
WRITE
READ
@SmaineDev
📆 progressive migration
🛣️ independent team
@SmaineDev
🐌 Double work
⏳ Synchronization problems
🕵️♂️ hard to debug
🤯 Different sources of truth
@SmaineDev
*write is already implemented
@SmaineDev
Legacy
Database 👴
API
WRITE
WRITE
READ
@SmaineDev
🐛 Lack of tests
🪨 Huge codebase
🔌 Should be plugged into the current codebase
⏳ We are late before the start
😭 Data Models can be different
🧩 Not everything is migrated
💡 Solution should have a good DX
@SmaineDev
Team API
Team ECOMMERCE
@SmaineDev
Slack message 📨
😱 Impostor syndrom++
@SmaineDev
@SmaineDev
Given I'm a developer
When I fetch an entity `utilisateur`
Then I should have an entity from an API
@SmaineDev
@SmaineDev
1️⃣ Create an "HttpRepository" to call the API
2️⃣ Transform the API resource into a model used in the app
3️⃣ Hydrate this model with relations from the database if any
4️⃣ Inject this HttpRepository in the Database Repo so we can migrate method per method
5️⃣ Write documentation
@SmaineDev
Controller
Repository
API
Deserialization
1️⃣
2️⃣
3️⃣
4️⃣
Transform an API model into a legacy model and hydrate with missing data and/or relations in database
3️⃣
<?php
#[ORM\Entity(repositoryClass: UtilisateurRepository::class)]
class Utilisateur
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
protected ?int $id = null; // Migrated ☁️
#[ORM\Column(length: 255)]
private ?string $prenom = null; // Migrated ☁️
#[ORM\Column(length: 255)]
private ?string $nom = null; // Migrated ☁️
#[ORM\Column(length: 255)]
private ?string $email = null; // Not Migrated 🛠
#[ORM\OneToOne(cascade: ['persist', 'remove'])] // Migrated ☁️
private ?Adresse $adresse = null;
#[ORM\OneToMany(mappedBy: 'utilisateur', targetEntity: Commande::class)] 🛠 // Not Migrated
private Collection $commandes;
// Getters/Setters tu connais 😁
}
@SmaineDev
<?php
class HomeController extends AbtractController
{
public function __construct(
private readonly UtilisateurRepository $utilisateurRepository
){}
/**
* @Route("/users/{id}", name="utilisateur_detail", method="GET")
*/
public function __invoke(string $id): Response
{
$utilisateur = $this->utilisateurRepository->find($id);
if (null === $utilisateur) {
throw new NotFoundHttpResponse(sprintf('Utilisateur "%s" non trouvé', $id);
}
return $this->render('utilisateur_detail.html.twig', [
'utilisateur' => $utilisateur
]);
}
}
// utilisateur_detail.html.twig
<h1>{{ utilisateur.nom }} {{ utilisateur.prenom}}</h1>
<h3>Adresse: {{ utilisateur.adresse.rue}},
{{ utilisateur.adresse.ville }} -
{{ utilisateur.adresse.codePostal}}
</h3>
<h4>Email: {{ utilisateur.email }}</h4>
{% if utilisateur.commandes|length > 0 %}
<h2>Commandes</h2>
<ul>
{% for commande in utilisateur.commandes %}
<li>{{ commande.nom }} {{ commande.prix }} €
<a href="{{ path('detail_commande', {id: commande.id}) }}">Voir commande</a>
</li>
{% endfor %}
<ul>
{% endif %}
<h5><a href="{{ path('liste_utilisateur') }}">Liste utilisateur</a> </h5>
{
"id":"42",
"lastName":"Doe",
"firstName":"John",
"address":{
"streetName":"avenue de bretagne",
"zipCode":"59000",
"city":"Lille"
}
}
GET /users/42
@SmaineDev
1️⃣ Create an "HttpRepository" to call the API
# http_client.yaml
http_client:
scoped_clients:
api.client:
base_uri: '%env(API_BASE_URI)%'
# configuration
// HttpUserRepository.php
namespace App\Http\Repository;
class HttpUserRepository
{
public function __construct(
private readonly HttpClientInterface $apiClient){}
public function find($id)
{
$response = $this->apiClient->request('GET', '/users/'. $id);
// ...
}
}
2️⃣ Transform the API resource into a model used in the app
// HttpUserRepository.php
namespace App\Http\Repository;
use App\Http\Model\User;
class HttpUserRepository
{
public function __construct(
private readonly HttpClientInterface $apiClient,
private readonly SerializerInterface $serializer){}
public function find($id)
{
$response = $this->apiClient->request('GET', '/users/'. $id);
return $this->serializer->deserialize(
$response->getContent(),
User::class,
'json'
);
}
}
2️⃣ Transform the API resource into a model used in the app
// User.php
namespace App\Http\Model;
class User extends App\Data\Entity\Utilisateur
{
public function setId($id)
{
$this->id = $id;
}
public function setLastname($lastname)
{
$this->setNom($lastname);
}
public function setFirstname($firstname)
{
$this->setPrenom($firstname);
}
// ...
}
3️⃣ Hydrate this model with relations from the database if any
// UserNormalizer.php
namespace App\Http\Normalizer;
use App\Http\Model\User;
class UserNormalizer implements DenormalizerInterface
{
public function denormalize(mixed $data,
string $type,
string $format = null,
array $context = []) {
// 1️⃣ Transform the payload into an `Utilisateur` object
// 2️⃣ Fetch value that are not migrated
// 3️⃣ Fetch relations that are not migrated
}
public function supportsDenormalization(mixed $data,
string $type,
string $format = null) {
return User::class === $type;
}
}
class UserNormalizer implements DenormalizerInterface
{
// 1️⃣ Transform the payload into an Utilisateur object
private function hydrateRoot($object, array $data = [])
{ // $data contains 👇
$data = [
'lastname' => 'Doe',
'firstname' => 'John'
// ..
];
foreach ($data as $property => $value) {
/** @var PropertyAccessorInterface $propertyAccessor */
$this->propertyAccessor->setValue($object, $property, $value)
}
$object->setId($data['id']);
return $object;
}
public function denormalize(mixed $data,
string $type,
string $format = null,
array $context = []) {
// 1️⃣ Transform the payload into an Utilisateur object
$utilisateur = $this->hydrateRoot(new User(), $data);
// ...
}
class UserNormalizer implements DenormalizerInterface
{
public function denormalize(mixed $data,
string $type,
string $format = null,
array $context = []) {
// 1️⃣ Transform the payload into an Utilisateur object
$utilisateur = $this->hydrateRoot(new User(), $data);
// ...
}
{
"id":"42",
"lastName":"Doe",
"firstName":"John",
"address":{
"streetName":"avenue de bretagne",
"zipCode":"59000",
"city":"Lille"
}
}
$utilisateur->getId(); // 42
$utilisateur->getNom(); // "Doe"
$utilisateur->getPrenom(); // "John"
$utilisateur->getAddresse()->getRue(); // "avenue de bretagne"
...
class UserNormalizer implements DenormalizerInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager) {}
// 2️⃣ Fetch value that are not migrated
protected function hydrateDatabaseProperties(
array $databaseFields,
string $entityClass,
$object): void
{
if ([] === $databaseFields) {
return;
}
$alias = 'entity';
\array_walk($databaseFields, function (&$a, $key, $alias) {
$a = $alias.'.'.$a;
},$alias);
$result = $this->entityManager->createQueryBuilder()
->from($entityClass, $alias)
->select($databaseFields)
->where($alias.'.id = :id')
->setParameter('id', $object->getId())
->getQuery()
->getSingleResult(AbstractQuery::HYDRATE_ARRAY);
foreach ($result as $field => $value) {
/** @var PropertyAccessorInterface */
$this->propertyAccessor->setValue($object, $field, $value);
}
}
// ...
}
use App\Data\Entity\Commande;
class UserNormalizer implements DenormalizerInterface
{
// 3️⃣ Fetch relations that are not migrated
private function hydrateDatabaseRelations($object): void
{
$commandes =
$this->entityManager
->getRepository(Commande::class)
->findBy(['utilisateur' => $object->getId()]);
}
if ([] !== $commandes) {
$this->propertyAccessor->setValue(
$object,
'commandes',
$result
);
}
}
final class RelationConfiguration
{
public function __construct(
private readonly string $fqcn,
private readonly string $identifier,
private readonly string $propertyPath,
) {
}
}
use RelationConfiguration;
final class UserConfiguration
{
public function getDatabaseRelations(): array
{
return [
new RelationConfiguration(Commande::class, 'utilisateur', 'commandes'),
];
}
public function getDatabaseProperties(): array
{
return ['email'];
}
}
class UserNormalizer implements DenormalizerInterface
{
private function hydrateDatabaseRelations(array $relationConfigurations, $object): void
{ /** @var RelationConfiguration[] $relationConfigurations */
foreach ($relationConfigurations as $relationConfiguration) {
$result = $this->entityManager
->getRepository($relationConfiguration->getFqcn())
->findBy([$relationConfiguration->getIdentifier() => $object->getId()]);
}
if ([] !== $result) {
$this->propertyAccessor->setValue($object,
$relationConfiguration->getPropertyPath(),
$result
);
}
}
public function denormalize(/**..*/)
{
// ...
// 3️⃣ Fetch relations that are not migrated
$userConfiguration = new UserConfiguration();
$this->hydrateDatabaseRelations(
$userConfiguration->getDatabaseRelations(),
$utilisateur
);
}
class UserNormalizer implements DenormalizerInterface
{
public function denormalize(/**..*/)
{
// 1️⃣ Transform the payload into an Utilisateur object
$utilisateur = $this->hydrateRoot(new User(), $data);
$userConfiguration = new UserConfiguration();
// 2️⃣ Fetch value that are not migrated
$this->hydrateDatabaseProperties(
$userConfiguration->getDatabaseProperties(),
Utilisateur::class, $utilisateur);
// 3️⃣ Fetch relations that are not migrated
$this->hydrateDatabaseRelations(
$userConfiguration->getDatabaseRelations(),
$utilisateur
);
return $utilisateur;
}
class UtilisateurRepository extends ServiceEntityRepository
{
public function __construct(
ManagerRegistry $registry,
private readonly HttpUserRepository $httpUserRepository) {
parent::__construct($registry, Utilisateur::class);
}
public function find($id, $lockMode = null, $lockVersion = null)
{
return $this->httpUserRepository->find($id);
}
public function findUtilisateurArchive()
{
return $this->createQuesryBuilderBlabla...
}
@SmaineDev
abstract class AbstractHttpRepository
{
abstract public function getModelClassName(): string;
abstract public function getResourcesUri(): string;
public function __construct() {
$this->cache = new Symfony\Component\Cache\Adapter\ArrayAdapter();
}
public function find(string $id)
{
return $this->doFind(sprintf('/%s', $this->getResourcesUri()));
}
private function doFind(string $uri)
{
$cacheKey = \str_replace('/', '', $uri);
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
return $item->get();
}
$object = $this->serializer->deserialize(
$this->apiClient->request('GET', $uri)->getContent(),
$this->getModelClassName(),
'json'
);
$item->set($object);
$this->cache->save($item);
return $object;
}
}
abstract class AbstractNormalizer implements DenormalizerInterface,DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
public function __construct(
protected readonly EntityManagerInterface $entityManager){}
abstract public function supportsDenormalization(/**..*/);
abstract protected function doDenormalize(/**..*/);
protected function hydrateRoot($object, array $data = [])
{// Tambouille 🍲}
protected function hydrateDatabaseProperties(
array $databaseFields,
string $entityClass, $object
): void {// Tambouille 🔪}
protected function hydrateDatabaseRelations(
array $relationConfigurations,
$object): void {// Tambouille 🍽️}
public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
{
retrun array_key_exists('@id', $data)
? $this->doDenormalize($data);
: $this->denormalizer->denormalize($data, $type.'[]', 'json');
}
}
class UserNormalizer extends AbstractNormalizer
{
public function doDenormalize(/**..*/)
{
/** @var User $user */
$user = $this->hydrateRoot(new User(), $data);
$userConfiguration = new UserConfiguration();
$this->hydrateDatabaseProperties(
$userConfiguration->getDatabaseProperties(),
Utilisateur::class,
$user);
$this->hydrateDatabaseRelations(
$userConfiguration->getDatabaseRelations(),
$user);
return $user;
}
public function supportsDenormalization(/**..*/)
{
return User::class === $type;
}
}
Given I'm a developer
When I fetch an entity from the database with a migrated relation
Then I should have the migrated relation set to my entity
use App\Http\Config;
#[ORM\Entity(repositoryClass: CommandeRepository::class)]
class Commande implements ContainsApiResource
{
// ..
#[ORM\Column(length: 255)]
private ?string $nom = null;
#[ORM\Column]
private ?float $prix = null;
//..
public function setUtilisateur(Utilisateur $utilisateur)
{
$this->utilisateur = $utilisateur;
}
public function getRelationsConfiguration(): array
{
return [
new RelationConfiguration(
User::class, // FQCN
'id', // identifier to fetch the value
'utilisateur' // propertyPath
),
];
}
}
class EntityWithMigratedRelationPostLoadSubscriber implements EventSubscriberInterface
{
private PropertyAccessorInterface $propertyAccessor;
public function __construct(
#[TaggedLocator('app.http_repository', defaultIndexMethod: 'getModelClassName')]
private ContainerInterface $httpRepositoryLocator ) {}
public function getSubscribedEvents(): array
{
return [Events::postLoad];
}
public function postLoad(PostLoadEventArgs $loadEventArgs)
{
if ($loadEventArgs->getObject() instanceof ContainsApiResource) {
/** @var $object ContainsApiResource */
$object = $loadEventArgs->getObject(); // Object from Database
foreach ($object->getRelationsConfiguration() as $relationConfiguration) {
$httpRepository = $this->httpRepositoryLocator->get($relationConfiguration->getFqcn());
$property = $this->propertyAccessor->getValue(
$object,
$relationConfiguration->getPropertyPath()
); // Entity Proxy zombie
$idValue = $this->propertyAccessor->getValue(
$property,
$relationConfiguration->getIdentifier()
); // Id of Object to fetch
$fetched = $httpRepository->find($idValue); // Object from API
$this->propertyAccessor->setValue(
$object,
$relationConfiguration->getPropertyPath(),
$fetched
);
}
}
}
1️⃣ Create a (Dummy)HttpRepository that extends `AbstractHttpRepository` => implements `getResourcesUri` and `getModelClassName`
2️⃣ Create a new model (Dummy) if the legacy and the API models are different.
3️⃣ Create a (Dummy)Normalizer that extends `AbstractNormalizer`
4️⃣ Create (Dummy)Configuration if you need to fetch properties/relations from legacy
5️⃣ Implements `ContainsApiResource` in entities that are linked to migrated resource
👉🏽 How to fetch Dummy resource from API
@SmaineDev
💪🏿 You're able to do what you were asked for
👣 A gradual migration includes trade-offs
🍝 Doctrine relationships can be toxic
✅ Test your application
📚 Mastering your framework helps a lot
🖤 Symfony c'est 1 truc de ouf 😍
@SmaineDev
💻 https://github.com/ismail1432/demo_api_platform_con_23
🎨 https://github.com/ismail1432/conferences
@SmaineDev