@SmaineDev

🥋Trainer - Developer 🐛

 @SmaineDev

Context

Team API

Team ECOMMERCE

 @SmaineDev

TEAM API

Legacy

Database 👴

New

Database 👶

Double

writing 🖊️

WRITE

READ

WRITE

 @SmaineDev

TEAM ECOMMERCE

Legacy

Database 👴

TEAM API

WRITE

WRITE

READ

 @SmaineDev

Double writing

For the win 🔥

📆 progressive migration

🛣️ independent team

 @SmaineDev

Double writing

problem(s) 😅

🐌 Double work

⏳ Synchronization problems 

🕵️‍♂️ hard to debug

🤯 Different sources of truth

 @SmaineDev

GOAL 🎯

Having ONLY one source of truth!

 

👉🏽 Write* and READ from the API 💫

*write is already implemented 

 @SmaineDev

Goal 🎯

Legacy

Database 👴

API

WRITE

WRITE

READ

ECOMMERCE

 @SmaineDev

More context

🐛 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

Context

Team API

Team ECOMMERCE

 @SmaineDev

Start 🏁

      Slack message 📨

 

😱 Impostor syndrom++

 @SmaineDev

Lead Developer role 🍄

 @SmaineDev

what we want?

Given I'm a developer

When I fetch an entity `utilisateur`

Then I should have an entity from an API

 @SmaineDev

Let's CREATE A BATTLE PLAN 🗺️

 @SmaineDev

Battle Plan 🗺️

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

Battle Plan 🗺️

1️⃣

2️⃣

3️⃣

4️⃣

 Transform an API model into a legacy model and hydrate with missing data and/or relations in database

3️⃣

Codebase 💻

<?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

Codebase 💻

<?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>

Codebase 💻

{
   "id":"42",
   "lastName":"Doe",
   "firstName":"John",
   "address":{
      "streetName":"avenue de bretagne",
      "zipCode":"59000",
      "city":"Lille"
   }
}

API ☁️

GET /users/42

 @SmaineDev

Let's Code 💻

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

Migrate method by method

Improvements 💅

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

It's not over yet!😆

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

Documentation 📝

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

🎬 Behind the scenes.

Feature Flag, Profiler, Serializer, Log, StopWatch, Property Accessor, EventS, Adapter, Cache, Service Locator, DecoratioN...

 @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 😍

CONCLUSION 👋🏽

 @SmaineDev

Thanks 👋🏽

💻 https://github.com/ismail1432/demo_api_platform_con_23

🎨 https://github.com/ismail1432/conferences

 @SmaineDev