Article écrit par Martin Catty
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
ont 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.