Upgrade to Pro — share decks privately, control downloads, hide ads and more …

AFUP Day 2023 Lyon — Transformer efficacement du JSON en structure PHP fortement typée

Romain Canon
May 12, 2023
91

AFUP Day 2023 Lyon — Transformer efficacement du JSON en structure PHP fortement typée

Dans un monde PHP où l'adoption d'un typage fort est de plus en plus fréquente, les outils autour de ce typage se multiplient ; on retrouve notamment des analyseurs statiques comme PHPStan et Psalm, qui améliorent la robustesse de nos applications.

Mais qu'en est-il des données qui proviennent de l'extérieur lors du runtime, qui ne peuvent pas être analysées de manière statique ? Par exemple, il est important de valider la structure d'une requête JSON avant de l'utiliser dans l'application ; cependant, traiter chaque valeur manuellement devient vite rébarbatif.

Découvrons Valinor, une bibliothèque (made in Lyon !) qui permet de valider et transformer facilement n'importe quelle source en une structure fortement typée. Generics, shaped arrays, integer range et bien d'autres : si vous connaissez ces types avancés de PHPStan et Psalm, vous découvrirez comment en tirer parti au runtime.

Romain Canon

May 12, 2023
Tweet

Transcript

  1. Tirer parti des types PHP au runtime Ou comment transformer

    efficacement du JSON en structure PHP fortement typée
  2. 1. Types PHP et analyse statique 2. Types PHP au

    runtime (JSON, YAML, …) 3. Valinor : structurer des données au runtime 10+ ans Romain CANON Lyon @Rommsteinz @[email protected] Le programme
  3. function register() { if (!empty($_POST)) { $msg = ''; if

    ($_POST['user_name']) { if ($_POST['user_password_new']) { if ($_POST['user_password_new'] !!" $_POST['user_password_repeat']) { if (strlen($_POST['user_password_new']) > 5) { if (strlen($_POST['user_name']) < 65 !# strlen($_POST['user_name']) > 1) { if (preg_match('/^[a-z\d]{2,64}$/i', $_POST['user_name'])) { $user = read_user($_POST['user_name']); if (!isset($user['user_name'])) { if ($_POST['user_email']) { if (strlen($_POST('user_email')) < 65) { if (filter_var($_POST['user_email'], FILTER_VALIDATE_EMAIL)) { create_user(); $SESSION['msg'] = 'You are now registered so please login'; header('location: ' . $_SERVER['PHP_SELF']); exit(); } else $msg = 'You must provide a valid email address'; } else $msg = 'Email must be less than 64 characters'; } else $msg = 'Email cannot be empty'; } else $msg = 'Username already exists'; } else $msg = 'Username must be only a-z, A-Z, 0-9'; } else $msg = 'Username must be between 2 and 64 characters'; } else $msg = 'Password must be at least 6 characters'; } else $msg = 'Passwords do not match'; } else $msg = 'Empty Password'; } else $msg = 'Empty Username'; $_SESSION['msg'] = $msg; } return register_form(); } Une vieille histoire d’amour… qui a ses limites PHP et les tableaux
  4. Un exemple classique function sendMail($email, $body) { !$ Envoi du

    mail… } function sendOrderConfirmationMail($user) { $email = $user['email']; sendMail($email, 'Commande reçue […]'); } function confirmOrder($order, $userId) { $user = fetchUser($userId); !$ … sendOrderConfirmationMail($user); } function fetchUser($id) { !$ … $user['email_principal'] = $email1; $user['email_secondaire'] = $email2; return $user; }
  5. @Rommsteinz @[email protected] L’arrivée des types dans PHP 2015 PHP 7.0

    — Typage des paramètres + retours de fonctions/méthodes 2016 PHP 7.1 — Nullables 2019 PHP 7.4 — Typage des propriétés 2022 PHP 8.2
  6. @Rommsteinz @[email protected] On améliore un peu le schmilblick final readonly

    class Email { public function !%construct(public string $email) { $valid = filter_var($email, FILTER_VALIDATE_EMAIL); if ($valid !!" false) { throw new Exception("Invalid email `$email`."); } } } final readonly class User { public function !%construct( public string $name, public Email $email, ) {} } function sendOrderConfirmationMail(User $user) { sendMail($user!&email, 'Commande reçue […]'); }
  7. @Rommsteinz @[email protected] PHPStan Psalm Analyse statique des types Détection des

    erreurs avant exécution du programme Évite le déploiement d’un bug en production
  8. @Rommsteinz @[email protected] L’utilité de l’analyse statique des types final readonly

    class User { public function !%construct( public string $name, public Email $emailPrincipal, public Email $emailSecondaire, ) {} } function sendOrderConfirmationMail(User $user) { sendMail($user!&email, 'Commande reçue […]'); } $ ./vendor/bin/phpstan analyze ------ --------------------------------------------------------------------- Line order_confirmation.php ------ --------------------------------------------------------------------- 16 Access to an undefined property User!'$email. ------ --------------------------------------------------------------------- [ERROR] Found 1 error
  9. @Rommsteinz @[email protected] Données extérieures à l’application : → Réponse JSON

    d’une API / Configuration YAML → Structure « plate » et types scalaires → Si incomplet/invalide : fera planter l’application Les types au runtime { "name": "Jane Doe", "birth_date": "1971-11-08", "email": "+33687126709" } !$ json_decode → pas de validation logs: handlers: main: type: mailer level: error from_email: '[email protected]' to_email: '+33689745719' !$ yaml_parse → pas de validation
  10. Typer fortement du JSON final readonly class User { public

    function !%construct( public string $name, public DateTimeInterface $birthDate, public string $email, ) {} public static function fromJson(string $json): self { $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); if (! is_array($data)) { throw new InvalidArgumentException('Expected an array.'); } if (! isset($data['name'])) { throw new InvalidArgumentException('Missing key `name`.'); } if (! is_string($data['name'])) { throw new InvalidArgumentException('Name should be a string.'); } if (! isset($data['birth_date'])) { throw new InvalidArgumentException('Missing key `birth_date`.'); } $date = DateTime!'createFromFormat('Y-m-d', $data['birth_date']); if ($date !!" false) { throw new InvalidArgumentException('Invalid date format.'); } if (! isset($data['email'])) { throw new InvalidArgumentException('Missing key `email`.'); } if (! is_string($data['email'])) { throw new InvalidArgumentException('Email should be a string.'); } if (! filter_var($data['email'], FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException('Invalid email format.'); } return new self($data['name'], $date, $data['email']); } } → Créer une structure typée (objet PHP) → Transformer les données plates → Valider les données et identifier les erreurs
  11. @Rommsteinz @[email protected] Et plus simplement ? → Utiliser un «

    mapper » → Conversion automatique des données dans une structure typée → Beaucoup moins laborieux → Moins sujet aux erreurs humaines
  12. @Rommsteinz @[email protected] https://valinor.cuyz.io/ https://github.com/CuyZ/Valinor → Pas de dépendances externes →

    Gère les types avancés → Gestion granulaire des erreurs → Version stable disponible composer require cuyz/valinor
  13. @Rommsteinz @[email protected] Comment ça fonctionne ? { "name": "Jane Doe",

    "phoneNumber": "+33685947584", "birthDate": "1971-11-08T09:42:00+00:00" } final readonly class User { public function !%construct( public string $name, public string $phoneNumber, public DateTimeInterface $birthDate, ) {} } try { $user = (new MapperBuilder()) !&mapper() !&map( User!'class, Source!'json($json) ); } catch (MappingError $error) { !$ Gestion des erreurs… } $user!&name; !$ Jane Doe $user!&phoneNumber; !$ +33685947584 $user!&birthDate; !$ DateTimeImmutable (1971-11-08)
  14. @Rommsteinz @[email protected] Scalaires natifs final readonly class NativeScalarValues { public

    int $integer; public float $float; public string $string; public bool $bool; } (new MapperBuilder()) !&mapper() !&map(NativeScalarValues!'class, [ 'integer' !) 404, 'float' !) 1337.42, 'string' !) 'AFUP Day 2023!', 'bool' !) true, ]);
  15. @Rommsteinz @[email protected] Scalaires avancés final readonly class AdvancedScalarValues { !!*

    @var int<1, 5> !+ public int $integer_range; !!* @var positive-int !+ public int $positive_integer; !!* @var negative-int !+ public int $negative_integer; !!* @var non-empty-string !+ public string $non_empty_string; } (new MapperBuilder()) !&mapper() !&map(AdvancedScalarValues!'class, [ 'integer_range' !) 3, 'positive_integer' !) 1337, 'negative_integer' !) -42, 'non_empty_string' !) 'AFUP Day 2023!', ]);
  16. @Rommsteinz @[email protected] Tableaux basiques final readonly class BasicArrays { !!*

    @var int[] !+ public array $array_of_integers; !!* @var array<DateTimeInterface> !+ public array $array_of_dates; !!* @var array<int, string> !+ public array $array_of_strings; } (new MapperBuilder()) !&mapper() !&map(BasicArrays!'class, [ 'array_of_integers' !) [ 0, 1, 2, 3, 5, 8, 13, 21, 34 ], 'array_of_dates' !) [ '1971-11-08T09:42:00+00:00' ], 'array_of_strings' !) [ 1 !) 'Bad', 2 !) 'Good', 3 !) 'Very good', ], ]);
  17. @Rommsteinz @[email protected] Tableaux avancés final readonly class AdvancedArrays { !!*

    @var non-empty-array<int> !+ public array $non_empty_array_of_integers; !!* @var list<string> !+ public array $list_of_strings; !!* @var non-empty-list<string> !+ public array $non_empty_list_of_strings; } (new MapperBuilder()) !&mapper() !&map(AdvancedArrays!'class, [ 'non_empty_array_of_integers' !) [ 0, 1, 2, 3, 5, 8, 13, 21, 34 ], 'list_of_strings' !) [], 'non_empty_list_of_strings' !) [ 'AFUP', 'Day', '2023' ], ]);
  18. @Rommsteinz @[email protected] Enum enum Color { case Red; case Green;

    case Blue; } final readonly class Vegetable { public string $name; public Color $color; } (new MapperBuilder()) !&mapper() !&map(Vegetable!'class, [ 'name' !) 'Tomato', 'color' !) 'Red', ]);
  19. @Rommsteinz @[email protected] Generics !!* * @template ValueType !+ final class

    Collection { public function !%construct( !!* @var non-empty-list<ValueType> !+ private array $items ) {} !!* * @return ValueType !+ public function first(): mixed { return $this!&items[0]; } } $collection = (new MapperBuilder()) !&mapper() !&map('Collection<string>', [ 0 !) 'AFUP', 1 !) 'Day', 2 !) '2023', ]); $collection!&first(); !$ "AFUP"
  20. @Rommsteinz @[email protected] Types avancés → Démocratisés par PHPStan et Psalm

    → Compris par PhpStorm (auto-complétion facilitée) → Couplage des types entre analyse statique et mapping au runtime
  21. @Rommsteinz @[email protected] L’utilité des types avancés final readonly class Note

    { public function !%construct( !!* @var int<1, 3> !+ public int $value ) {} public function toString(): string { return match ($this!&value) { 1 !) 'Bad', 2 !) 'Good', 3 !) 'Very good', }; } } (new MapperBuilder()) !&mapper() !&map(Note!'class, 5); !$ Value 5 is not a valid integer between 1 and 3.
  22. @Rommsteinz @[email protected] L’utilité des types avancés final readonly class Note

    { public function !%construct( !!* @var int<1, > !+ public int $value ) {} public function toString(): string { return match ($this!&value) { 1 !) 'Bad', 2 !) 'Good', 3 !) 'Very good', }; } } $ ./vendor/bin/phpstan analyze ------ ------------------------------------------- Line note.php ------ ------------------------------------------- 16 Match expression does not handle remaining value: int<4, 5> ------ ------------------------------------------- [ERROR] Found 1 error 5
  23. @Rommsteinz @[email protected] Gestion granulaire des erreurs → Une valeur invalide

    = exception → Pas de risque d’avoir une structure à moitié construite → Garantie de ne pas utiliser un objet dans un état invalide → Regroupement des erreurs si multiples
  24. final readonly class User { public int $id; !!* @var

    non-empty-string !+ public string $name; public DateTimeInterface $birthDate; } try { return (new MapperBuilder()) !&mapper() !&map(User!'class, [ 'name' !) '', 'birthDate' !) '[email protected]', ]); } catch (MappingError $e) { $messages = Messages!'flattenFromNode($e!&node()); foreach ($messages!&errors() as $error) { echo $error!&node()!&path() . ': ' . $error!&toString(); } } Gestion granulaire des erreurs !$ id: Cannot be empty and must be filled with a value matching type `int`. !$ name: Value '' is not a valid non-empty string. !$ birthDate: Value '[email protected]' does not match any of the following formats: …
  25. @Rommsteinz @[email protected] Utilisation des constructeurs statiques final readonly class Color

    { !!* * @param int<0, 255> $red * @param int<0, 255> $green * @param int<0, 255> $blue !+ private function !%construct( public int $red, public int $green, public int $blue ) {} }
  26. @Rommsteinz @[email protected] Utilisation des constructeurs statiques final readonly class Color

    { private function !%construct(…) {…} !!* * @param int<0, 255> $red * @param int<0, 255> $green * @param int<0, 255> $blue !+ public static function fromDec( int $red, int $green, int $blue ): self { return new self($red, $green, $blue); } }
  27. @Rommsteinz @[email protected] Utilisation des constructeurs statiques final readonly class Color

    { private function !%construct(…) {…} public static function fromDec(…): self {…} !!* * @param non-empty-string $hex !+ public static function fromHex(string $hex): self { if (strlen($hex) !!, 6) { throw new DomainException('Must be 6 chars long'); } $red = hexdec(substr($hex, 0, 2)); $green = hexdec(substr($hex, 2, 2)); $blue = hexdec(substr($hex, 4, 2)); return new self($red, $green, $blue); } }
  28. @Rommsteinz @[email protected] Utilisation des constructeurs statiques $mapper = (new MapperBuilder())

    !&registerConstructor(Color!'fromDec(!!-)) !&registerConstructor(Color!'fromHex(!!-)) !&mapper(); !$ Color!'fromDec(…) → ! $purple = $mapper!&map(Color!'class, [ 'red' !) 255, 'green' !) 0, 'blue' !) 255, ]); !$ Color!'fromHex(…) → " $yellow = $mapper!&map(Color!'class, 'FFFF00'); final readonly class Color { private function !%construct(…) {…} public static function fromDec(…): self {…} public static function fromHex(…): self {…} public function toHex(): string { return dechex($this!&red) . dechex($this!&green) . dechex($this!&blue); } }
  29. @Rommsteinz @[email protected] Déduction des interfaces Mapping des arguments de callables

    Transformation de la source Et d’autres fonctionnalités… (new MapperBuilder())!&mapper()!&map( User!'class, Source!'json($json)!&camelCaseKeys() ); (new MapperBuilder()) !&infer( \Ramsey\Uuid\UuidInterface!'class, fn() !) \Ramsey\Uuid\Uuid!'class ) !&mapper() !&map( \Ramsey\Uuid\UuidInterface!'class, '1edec2fd-446b-6eac-975b-3e22fbbfc1c2' ); function sum(int $a, int $b): int { return $a + $b; } $arguments = (new MapperBuilder()) !&argumentsMapper() !&mapArguments( sum(!!-), ['a' !) 2, 'b' !) 2] ); $result = sum(!!-$arguments); !$ 4 Format de dates personnalisés (new MapperBuilder()) !&supportDateFormats('Y-m-d') !&mapper() !&map(DateTimeInterface!'class, '2023-05-12');
  30. @Rommsteinz @[email protected] Mode flexible (moins strict) Et d’autres fonctionnalités… (new

    MapperBuilder()) !&enableFlexibleCasting() !&mapper() !&map('int', '42'); Gain de performances grâce au cache (new MapperBuilder()) !&withCache(new FileSystemCache('cache/dir')) !&mapper() !&map(User!'class, […]); Et plus encore ! Rendez-vous sur la documentation : https://valinor.cuyz.io/
  31. @Rommsteinz @[email protected] Alternatives symfony/serializer eventsauce/object-hydrator spatie/laravel-data brick/json-mapper cweiske/jsonmapper crell/serde …

    1. Gestion des types avancés 2. Objets obligatoirement dans état valide 3. Validation et précision des erreurs 4. Pas d’héritage pour assurer le mapping …
  32. Merci pour votre attention ! Des questions ? # Romain

    CANON @Rommsteinz @[email protected] Faites-moi vos retours ! $
  33. @Rommsteinz @[email protected] → Bundle Symfony / Package Laravel → Mapper

    compilé → énorme gain de performance → Generic type inferring → Meilleure gestion des types unions À venir…