Même dans un langage typé on est amené à faire de la validation des données car les types basiques comme String restent très permissifs. Refined Types permettent d'intégrer les règles de validation dans la définition des types ce qui réduit le boilerplate et renforce la type safety avantageusement.
Dans de ce talk je vous présente les Refined Types dans Scala: comment ça marche, à quel besoin ça répond, et comment les utiliser. Nous verrons des exemples avec la librairie de base et un retour d'expérience sur l'intégration avec Play dans dans un API REST.
2. Pourquoi Refined ?
Même dans un langage fortement typé on est souvent amené à faire un effort de
validation sur les données
Prenons le cas de String
- Type très permissif
- On peut mettre tout et n’importe quoi
Et pourtant dans la plupart des cas on vise une partie de domain des valeurs possibles
3. Pourquoi Refined ?
Mais il y a toujours moyen d’envoyer des données pas cohérentes
=> Ajouter une étape de validation mais avec un modèle plus complexe cela devient du
boilerplate code
def registerSpeaker( name: String, handle: String) = {...}
Exemple 1
registerSpeaker("@johnDoe", "John Doe") // ça peut arriver :(
registerSpeaker("John Doe", "@johnDoe") // OK :)
4. Pourquoi Refined ?
Exemple 2
// Fichier de configuration
api {
timout-sec = -10 #timeout négative !
port = 9000
url = "htp://myapi.com" #invalid protocol !
}
final case class ApiSettings(timoutSec: Int, port: Int , url: String )
val conf = pureconfig.loadConfig[ApiSettings]("api")
=> L’objet de configuration est bien chargé, mais on aura des problèmes Runtime
5. Comment peut-on :
- Renforcer la précision des types
- Réduire le code de validation
- Détecter les incohérences le plus tôt possible (compile-time ?)
6. Refined, le concept
Au lieu de chercher à valider toute valeur possible dans le domaine d’un Type basique,
on peut intégrer la validation au niveau de la définition de Type!
Type Refined = Type Basique + Predicate
=> Le domaine de ce nouveau type est réduit aux valeurs qui respectent la règle de
validation associée
7. Refined En Pratique
La librairie Refined en Scala:
● https://github.com/fthomas/refined
● Un projet TypeLevel
import eu.timepit.refined.api.Refined
import eu.timepit.refined.numeric.Positive
import eu.timepit.refined.refineMV
type PositiveInt = Int Refined Positive
Pour déclarer un refined type:
Pour instancier un refined value à partir d’un literal
val positive : PositiveInt = refineMV[Positive](5)
// positive: PositiveInt = 5
val fail = refineMV[Positive](-5)
// ERREUR de compilation : Predicate failed (-5 > 0)
8. Refined En Pratique
import eu.timepit.refined.api.Refined
import eu.timepit.refined.numeric.Positive
import eu.timepit.refined.auto._
type PositiveInt = Int Refined Positive
// avec l’import auto plus besoin d’utiliser explicitement refineMV[]
val positive: PositiveInt = 5
//positive: PositiveInt = 5
val result : Int = 15 + positive
// sans l'import d'auto il faut passer par .value
val result : Int = 15 + positive.value
L’objet auto permet la conversion automatique entre le refined type son type basique
9. Validation au compile-time
import eu.timepit.refined.api.{Refined, RefinedTypeOps}
import eu.timepit.refined.auto._
import eu.timepit.refined.W
import eu.timepit.refined.collection.NonEmpty
import eu.timepit.refined.string.StartsWith
type Name = String Refined NonEmpty
object Name extends RefinedTypeOps[Name,String]
type Handle = String Refined StartsWith[W.`"@"`.T]
object Handle extends RefinedTypeOps[Handle,String]
val name : Name = Name("John Doe") //name: Name = John Doe
val handle = Handle("@johnDoe") // handle: Handle = @johnDoe
val fail_handle = Handle("johnDoe")
// COMPILATION ERROR !
// Predicate failed: "johnDoe".startsWith("@").
10. Validation au compile-time
import eu.timepit.refined.api.{Refined, RefinedTypeOps}
import eu.timepit.refined.auto._
import eu.timepit.refined.W
import eu.timepit.refined.collection.NonEmpty
import eu.timepit.refined.string.StartsWith
type Name = String Refined NonEmpty
object Name extends RefinedTypeOps[Name,String]
type Handle = String Refined StartsWith[W.`"@"`.T]
object Handle extends RefinedTypeOps[Handle,String]
val name : Name = Name("John Doe") //name: Name = John Doe
val handle = Handle("@johnDoe") // handle: Handle = @johnDoe
val fail_handle = Handle("johnDoe")
// COMPILATION ERROR !
// Predicate failed: "johnDoe".startsWith("@").
L’objet compagnon défini en utilisant RefinedTypeOps permet d’avoir
11. Validation au run-time
Avec les Refined Types
- On ne peut plus passer des données incohérentes
- Les types sont précises et ne prennent que des valeurs pertinents
=> On passe d’un “Stringly Typed” modèle vers un “Strongly Typed” modèle
val input_name : String // valorisé au runtime
val input_handle: String // valorisé au runtime
val result: Either[String, Name] = Name.from(input_name)
def registerSpeaker(name:Name, handle: Handle) = {
....
}
val speaker: Either[String, Speaker] = for{
name <- Name.from(input_name)
handle <- Handle.from(input_handle)
} yield registerSpeaker(name, handle)
L’objet compagnon fournit aussi la méthode “from”
12. Predicates Numeriques
Dans le package eu.timepit.refined.numeric
Predicates simples par rapport à une valeur
Less[N] LessEqual[N]
Greater[N] GreaterEqual[N]
Positive Negative
Predicates sur une intervale
Interval.Open[L, H] Interval.OpenClosed[L, H]
Interval.ClosedOpen[L, H] Interval.Closed[L, H]
13. Predicates Logiques
Dans le package eu.timepit.refined.boolean
Not[P] negation of the predicate P
And[A, B] conjunction des Predicates A et B
Or[A, B] disjunction des Predicates A et B
AllOf[PS] conjunction des predicates dans PS
AnyOf[PS] disjunction des predicates dans PS
OneOf[PS] disjunction exclusive des predicates dans PS
14. Predicates de String & Collection
Dans le package eu.timepit.refined.string
StartsWith[S] EndsWith[S] Uri
MatchesRegex[S] Regex Url
Dans le package eu.timepit.refined.collection
Empty NonEmpty
Forall[P] Exists[P]
MinSize[N] MaxSize[N]
15. Exemples
type ID256 = String Refined And[NonEmpty, MaxSize[W.`256`.T]]
type CodePostale = String Refined MatchesRegex[W.`"[0-9]{5}"`.T]
type Latitude = Double Refined Interval.Closed[W.`-90d`.T, W.`90d`.T]
et ça peut aller loin ..
import shapeless.{::, HNil}
type TwitterHandle = String Refined AllOf[
StartsWith[W.`"@"`.T] ::
Not[MatchesRegex[W.`"(?i:.*twitter.*)"`.T]] ::
Not[MatchesRegex[W.`"(?i:.*admin.*)"`.T]] ::
Tail[Or[LetterOrDigit, Equal[W.`'_'`.T]]] ::
HNil ]
16. Intégration avec des librairies
● refined-pureconfig : lire les configuration avec des Refined types
● refined-scopt : lire les options de ligne de commande avec des Refined types
● refined-jsonpath : fournir un JSONPath predicate pour vérifier qu’un String est
un JSONPath valide
● refined-scalacheck : générer des valeurs arbitraires pour les Refined types avec
ScalaCheck.
● refined-cats
● refined-scalaz
17. Exemple PureConfig
// Fichier de configuration
api {
timout-sec = -10 #timeout négative
port = 9000
url = "htp://myapi.com" #invalid protocol
}
// case class avec des Refined Types
final case class RefinedApiSettings(timoutSec: PosInt,
port: Int Refined And[Greater[W.`1023`.T],Less[W.`65536`.T]],
url: String Refined Url)
val conf = pureconfig.loadConfig[ApiSettings]("api")
19. Refined avec Play
Pour utiliser les Refined types avec Play on utilise une librairie complémentaire
- https://github.com/kwark/play-refined
- SBT dependency : "be.venneborg" %% "play26-refined" % "0.3.0"
qui propose les fonctionnalités suivantes :
- Sérialisation/Désérialisation de JSON sous forme des Refined Types
- Binding/Unbinding des Refined Types dans les Forms
- Path/Query binding de Refined Types
- Traduire les erreurs Refined en erreurs Play standards
20. REX : Valider l’input d’un API Play avec Refined
import eu.timepit.refined.api.Refined
import eu.timepit.refined.collection.NonEmpty
import eu.timepit.refined.string.{StartsWith, Url}
import eu.timepit.refined.W
import play.api.libs.json.Json
case class SpeakerDTO (name: String Refined NonEmpty,
url: String Refined StartsWith[W.`"@"`.T])
object SpeakerDTO {
import be.venneborg.refined.play.RefinedJsonFormats._
implicit val speakerDTOfmt = Json.format[SpeakerDTO]
}
1- Définir les DTO avec des Refined Types :
21. REX Refined avec Play
import eu.timepit.refined.api.Refined
import eu.timepit.refined.collection.NonEmpty
import eu.timepit.refined.string.{StartsWith, Url}
import eu.timepit.refined.W
import play.api.libs.json.Json
case class SpeakerDTO (name: String Refined NonEmpty,
url: String Refined StartsWith[W.`"@"`.T])
object SpeakerDTO {
import be.venneborg.refined.play.RefinedJsonFormats._
implicit val speakerDTOfmt = Json.format[SpeakerDTO]
}
1- Définir les DTO avec des Refined Types :
22. 2- Utiliser les Refined DTO dans la signature de services
def register = Action.async(parsers.json(SpeakerDTO.speakerDTOfmt)){
implicit req => Future{
// call backend service
createSpeaker(req.body)
Created
}
}
3- Utiliser les Refined DTO dans la signature de services
def createSpeaker(speakerDTO: SpeakerDTO) = {
.....
}
REX : Valider l’input d’un API Play avec Refined
23. // Status : 400 Bad Request
// Body :
{
"obj.handle": [
{
"msg": [
"error.invalid"
],
"args": [
"List(not starting with: @)"
]
}
]
}
Requete
Réponse
POST : http://localhost:9000/api/speaker
Body :
{
"name" : "John Doe",
"handle" : "invalid_handle"
}
24. Conclusion
Type Refined = Type Basique + Predicate
L’utilisation de Refined Types permet de :
- Intégrer les règles business dans la définition des Types
- Centraliser et simplifier le processus de validation des données
- Detecter plus rapidement les incoherences
Aujourd’hui même avec un langage fortement typé comme Scala ou autre, au niveau du modèle des données on est souvent amené à faire un effort de validation sur les données car les types basiques , comme String par exemple sont très permissif
Dans un string on peut mettre tout ce qu’on veut .. un adresse mail, un numéro de tel, un code Postale, voir même tout un paragraphe.
En fait le domaine des valeurs possible est très large
Et pourtant dans la plupart des cas, ce qu’on cherche est est une partie bien spécifique de ce large domaine
Prenons cet exemple, on a une méthode de service pour enregistrer un Speaker
Le speaker est identifié par son Nom et son Handle Twitter, qu’on a modélisé en String
On appel la méthode avec le nom et le handle tout va bien
Mais il y a toujours moyen d’envoyer des données pas cohérentes .. ça peut être une chaîne vide , un handle twitter pas valide
On peut tout simplement se tromper au moment de l’appel et inverser les paramètres ..
Cela implique une erreur runtime, voir pire stocker des données pas cohérentes
Une solution est d’ajouter une étape de validation quelque part dans notre implémentation , pour ce cas la ça parait simple
Mais avec un modèle complexe cela devient un boilerplate code à gérer et ça peut même devenir une source d’erreur
Le concept de Refined peut répondre à ces besoins.
Au lieu de chercher à valider toute valeur possible dans le domaine, on peut intégrer la validation au niveau de la définition de Type
Comment faire , on définit un nouveau Type raffiné (Refined Type) , à partir d’un Type Basique et Un Predicate
Le Predicate est la ou les règles de validation qu’on souhaite associer
Le résultat est un nouveau type avec un domaine qui est réduit au valeurs qui respecte la règle
Revenons sur notre exemple de Name et Handler pour le speaker , on va pouvoir définir 2 types :
Name est un String qui ne doit pas être vide
Handle est un string qui doit commencer par @
On a défini aussi des objets compagnon,avec l’aide de RefinedTypeOps
Avec l’objet compagnon, on va avoir un apply méthode de disponible qu’on utilise dans cet exemple
C’est bien d’avoir la validation au niveau de compile time, mais en général on a très peu de literals dans nos programmes
La validation doit se faire au runtime aussi.
Pour notre exemple de speaker, quelque part on va recevoir deux String comme input
Avec l’objet compagnon défini auparavant, la méthode .from permet de valider le type basique en Runtime , et nous retourne either
la méthode registerSpeaker utilise les refined types comme paramètres , et avec un bloc de for comprehension on peut composer les Either et appeler la méthode
L’objet de configuration ne sera pas ce charge
Le message d’erreur est très parlant
Maintenant on va voir comment on peut utiliser Refined avec le framework Play.Cela est possible avec une libraires complémentaire : play-refined
Dans notre utilisation de Refined avec Play on a utilisé les fonctionnalités 1 et 2
En fait nous avons construite un API Rest avec play, donc on recevait des appels avec de JSON en input , et il y avait pas mal des règles de gestion à valider sur les données reçu.
On a commencé par construire tous nos DTO en utilisant des RefinedTypes, du coup toutes les règles métiers font partie de la définition des types
On peut appliquer cela sur notre exemple de Speaker
Grace à la libraire play-refined, notamment l’import de RefineJsonFormats, on peut générer automatiquement les Reader et Writer pour transformer le JSON directement
Toutes les méthodes de services prennent les Refined-DTO en paramètre
Et enfin au niveau des Actions , on va parser le body de request pour créer le DTO et le passer au service
Avec tout ça en place, on a une validation qui se fait en runtime , sans écrire du code de validation
Et si on reçoit un appel avec des données pas valides, l’API renvoie un message d’erreur assez parlant