Tour d'horizon de dry-type, dry-struct et dry-validation

Publié le 22 avril 2022 par Martin Catty | architecture - ruby

Cet article est publié sous licence CC BY-NC-SA

En attendant la sortie d’Hanami v2, qui possède quelques gems dry-rb en dépendances et après qu’Émilie nous a fait découvrir dry-transformer, passons à dry-struct, dry-types et dry-validation.

J’ai récemment eu l’occasion de les mettre en œuvre dans le cadre d’une application Ruby on Rails assez conséquente.

Je suis toujours frileux à l’idée d’ajouter de nouvelles dépendances, surtout dans des codebases déjà volumineuses ; mais les gems dry-rb ont cet avantage d’avoir elles-mêmes très peu, voire aucune dépendance.

Dans le contexte de dry-struct on a :

spec.add_runtime_dependency "dry-core", "~> 0.5", ">= 0.5"
spec.add_runtime_dependency "dry-types", "~> 1.5"
spec.add_runtime_dependency "ice_nine", "~> 0.11"

Sachant que j’avais aussi besoin de dry-types, on est somme toute sur un nombre de dépendances limité et souvent maintenues par les mêmes personnes.

Si vous vous dites que toutes ces gems semblent assez similaires et répondent aux mêmes usages c’est tout à fait normal pour une première impression.

Mais dry a cette philosophie très unixienne d’un outil par usage, quand bien même il semble y avoir des recouvrements.

Pourquoi utiliser ces gems ?

Le contexte métier était de récupérer une entrée avec plusieurs paramètres pour établir une simulation financière.

Dans ces entrées on pouvait avoir le genre, le métier, les revenus, etc. Certains paramètres étaient optionnels, d’autres non. Certaines valeurs étaient libres, d’autres contraintes. Pour certains paramètres on voulait des valeurs par défaut, mais pas toujours.

Bref rien de très exotique, mais je voulais évidemment éviter de me retrouver avec une action énorme dans un contrôleur enchainant les if / else et plutôt avoir une classe dédiée qui serait facile à tester en isolation.

Dans Rails on aurait pu partir sur ActiveModel, mais ici j’ai préféré m’aventurer avec Dry::Struct, dans l’optique de créer un service auquel je passerai directement mon entrée depuis mon contrôleur.

Dans l’action de mon contrôleur j’ai simplement :

def controller_action
  simulator = RetirementSimulator.build(
    user: current_user,
    status: params.dig(:params, :status),
    gender: params.dig(:params, :gender),
    career_start_year: params.dig(:params, :career_start_year),
    net_mensual_salary: params.dig(:params, :net_mensual_salary),
    forecast_annual_increase_rate: params.dig(:params, :forecast_annual_increase_rate),
    executive: params.dig(:params, :executive),
    established_official: params.dig(:params, :established_official),
    desired_mensual_saving_efforts: params.dig(:params, :desired_mensual_saving_efforts),
    desired_age_at_retirement: params.dig(:params, :desired_age_at_retirement)
  )
  response = simulator.call
  render json: response.payload, status: response.status
end

J’utilise donc uniquement l’action comme un passe-plat. En termes de tests, l’idée était de créer des « profils », c’est-à-dire des jeux de paramètres, certains cohérents, d’autres non et d’en vérifier la sortie.

Avec ce setup mes tests offrent l’avantage d’être complètement indépendants du reste de l’application et je n’ai globalement que ma classe à tester pour m’assurer que tout fonctionne comme attendu.

RetirementSimulator.build renvoie une instance d’objet qui peut varier. Si les paramètres ne sont pas valides, il s’agira d’un objet de type NullRetirementSimulator. Autrement il peut s’agir d’un objet de type OpenStruct, qui sera en charge de renvoyer le payload et le statut au même format.

def call
  save
  OpenStruct.new(payload: as_json, status: :ok)
end

dry-types

dry-types va nous permettre, entre autres, de vérifier et/ou caster automatiquement nos paramètres dans le format attendu.

Dans un mode strict (ex : attribute :age, Types::Strict::Integer), si vous passez une valeur non autorisée pour votre attribut vous vous ferez jeter.

Dans un mode coercible, dry-types essaiera de caster pour vous les valeurs passées. Si vous passez la chaine de caractères "18" pour l’âge, il la convertira automatiquement en entier.

Pour cela il utilisera uniquement les méthodes mises à disposition par Ruby au niveau kernel, par exemple "18".to_i.

Pour les types qui ne sont pas directement convertibles avec Ruby il faut utiliser Types::Params, par exemple Types::Params::Bool que l’on va retrouver juste après et qui peut transformer "1" en true par exemple.

dry-types permet aussi d’ajouter des contraintes sur les valeurs, gérer des énumérateurs, des valeurs optionnelles, bref toutes les choses dont on va avoir besoin.

dry-struct

dry-struct est lui construit au-dessus de dry-types. Au lieu d’utiliser dry-types comme un mixin vous pouvez créer votre propre classe qui disposera des méthodes de dry-types.

Étant donné qu’on crée un service utilisable en isolation, ça colle plutôt pas mal 🙂

Voilà quelques morceaux choisis de notre code que l’on détaillera juste après :

class RetirementSimulator < Dry::Struct
  STATUS = Dry.Types::String.enum('employee', 'independent', 'official')

  transform_types do |type|
    if type.default?
      type.constructor do |value|
        value.nil? ? Dry::Types::Undefined : value
      end
    else
      type
    end
  end

  attribute :user, Dry.Types.Instance(User)
  attribute :status, STATUS.optional
  attribute :career_start_year, Dry.Types::Coercible::Integer.optional
  attribute? :average_annual_increase_rate, Dry.Types::Coercible::Float.default(0.0)
  attribute :executive, Dry.Types::Params::Bool.optional

  delegate :year_of_birth,
           :profile,
           to: :user
end

Du côté des évidences, la partie enum nous permet de gérer notre attribut status qui prendra l’une des valeurs autorisées.

On peut bien sûr demander à un attribut d’être d’un certain type (interne à Ruby ou non), c’est le cas ici avec Dry.Types.Instance(User).

La notion de attribute? (avec le ?) permet de définir qu’un attribut est optionnel, si la clé est absente la validation n’échouera pas.

À ne pas confondre avec optional qui indique que la valeur n’est pas requise, mais dans ce cas la clé doit tout de même être présente !

Un attribut peut être optionnel mais avec une valeur par défaut. Si la clé n’est pas passée on utilisera alors la valeur prédéfinie (c’est le cas de average_annual_increase_rate).

Dans le cas contraire on utilisera ce qui est passé (si cela respecte le type ou que c’est castable).

Vous voyez dans l’exemple différents types de données, Integer, String, Float et Bool.

Dans dry-types une valeur nil est considérée comme valable, elle ne sera donc pas résolue sur la valeur par défaut, ce qui n’est pas forcément ce qu’on veut.

C’est un comportement qui a changé selon les versions et qui a été sujet à discussions.

Au passage, c’est un point qui peut être irritant dans dry, il faut être vigilant sur des comportements qui peuvent changer d’une mineure à l’autre et que vous aurez parfois du mal à retrouver dans la documentation.

C’est notre bloc transform_types, à première vue pas très élégant, qui permet de changer ce comportement et d’utiliser la valeur par défaut si elle existe quand on reçoit nil.

Je n’ai pas inventé l’eau chaude sur ce point, c’est la méthode officielle conseillée.

Gérer les objets invalides

Dans le cas où nos paramètres ne respecteraient pas notre spécification, le constructeur nous renverra une exception Dry::Struct::Error.

Ce qui nous permet dans notre méthode build, appelée depuis notre constructeur, de gérer cela très simplement :

def self.build(**args)
  simulation = new(args)
  # here comes the magic
rescue Dry::Struct::Error
  NullRetirementSimulator.new(payload: {}, status: :unprocessable_entity)
end

En effet, en héritant de dry-struct j’hérite d’un constructeur par défaut qui me permet de directement passer mes arguments pour construire un objet disposant d’accesseurs sur les attributs mis en place.

Dans le cas où mon objet est invalide, car ne respectant pas les contraintes définies, j’utilise le pattern Null Object Pattern (NullRetirementSimulator est une bête classe avec 2 accesseurs) qui permettra de renvoyer les valeurs et statuts HTTP voulus dans le cas où mon entrée est invalide, sans avoir à le gérer comme un cas particulier dans l’action de mon contrôleur.

Et dry-validation alors ?

Ah ! Je vous ai caché un petit morceau dans le snippet ci-dessus, la partie «here comes the magic».

Son travail est de, selon les paramètres passés, appeler le bon simulateur (ce sont des simulateurs différents selon que la personne ait travaillé dans le privé ou dans le public, etc.).

J’utilise donc le pattern adapter en m’assurant que mes simulateurs, quels qu’ils soient, répondent à la même interface. Ma classe RetirementSimulator agit comme un routeur.

Au sein de ces simulateurs spécifiques je veux valider d’autres choses, qui ne sont plus du domaine des paramètres reçus via HTTP mais uniquement de la logique métier.

Pour cela on va définir un contrat :

class RetirementSimulatorContract < Dry::Validation::Contract
  STARTING_YEAR_TO_COMPUTE_SUPPLEMENT = 1973

  params do
    required(:year_of_birth).value(:integer)
    required(:career_start_year).value(:integer)
  end

  rule(:career_start_year) do
    key.failure("career has to start after 1972") if value < STARTING_YEAR_TO_COMPUTE_SUPPLEMENT
  end

  rule(:career_start_year, :year_of_birth) do
    if values[:career_start_year] - values[:year_of_birth] < 14
      key.failure("is too early, can't start working before 14")
    end
  end
end

Notre contrat va valider que la personne qui lance le simulateur n’indique pas avoir commencé à travailler avant 14 ans ni avant 1972.

Pour cela Dry::Validation::Contract nous permet de définir des rules qui vont prendre un ou plusieurs paramètres en entrée.

On mixe ici des valeurs qui viennent d’une part de notre base de données (year_of_birth, qui vient de notre objet user) et d’autre part de nos paramètres (career_start_year) ; on voit donc bien qu’on n’est plus uniquement dans une logique de validation d’entrée.

Pour invoquer ce contrat c’est aussi simple que :

contract = RetirementSimulatorContract.new
res = contract.call(
  year_of_birth: simulation.year_of_birth,
  career_start_year: simulation.career_start_year
)

Les éventuelles erreurs se trouveront dans res.errors.

Conclusion

Ces gems dry offrent une manière élégante (à mon avis) de garder du code propre et testable de façon isolée. Qui plus est, le nombre limité de dépendances permet de facilement venir greffer ces outils dans une codebase existante.

Cela peut donc être un excellent moyen de tirer vers le haut la qualité d’une base de code ayant déjà un peu de vécu de façon progressive et sans avoir à modifier l’existant.


L’équipe Synbioz.
Libres d’être ensemble.