Introduction à DeriveGeneric en Haskell, exemple avec le format JSON

Voir aussi : video youtube - video peertube - code source

En Haskell, DeriveGeneric est une fonctionnalité qui analyse la définition d’un type. Ceci permet de générer automatiquement des instances de classe de type. Plus généralement, le principe des Generics en Haskell est assez proche de ce qu’on appelle souvent réflexion dans d’autres langages. Les Generics sont un outil très intéressant mais cet article traite uniquement d’un cas d’utilisation : importer/exporter un type Haskell depuis/vers le format JSON.

Le format JSON

Le format JSON permet de représenter des données sous une forme inspirée des objets JavaScript. C’est un format plus léger que XML, et qui souvent utilisé pour récupèrer des données depuis des API web.

Par exemple, la commande suivante demande à l’API Github des informations sur le dépôt haskell/aeson.

curl https://api.github.com/repos/haskell/aeson

En réponse, le serveur envoie les données correspondantes, au format JSON.

{
  "id": 1280014,
  "name": "aeson",
  "private": false,
  "owner": {
    "login": "haskell",
    "id": 450574,
    ...
  },
  "description": "A fast Haskell JSON library",
  ...
} 

Les types enregistrements

Pour rappel, Haskell permet de définir des types enregistrements, c’est-à-dire des types algébriques avec des champs nommés et typés. C’est donc un format clé-valeur similaire à un objet en JSON.

Par exemple, on peut définir un type Repo pour réprésenter un dépôt Github (partiellement).

data Repo = Repo
    { id :: Int
    , name :: Text
    , private :: Bool
    , description :: Text
    , owner :: Owner
    } 

Instanciation automatique

La bibliothèque Haskell Aeson permet d’importer et d’exporter un type depuis/vers le format JSON.

Par exemple, on peut écrire le fichier Repo.hs suivant.

{-# LANGUAGE DeriveGeneric #-}

module Repo where

import Data.Aeson
import Data.Text
import GHC.Generics

import Owner

data Repo = Repo
    { id :: Int
    , name :: Text
    , private :: Bool
    , description :: Text
    , owner :: Owner
    } deriving (Generic, Show)

instance FromJSON Repo
instance ToJSON Repo

Dans ce code, on a essentiellement reprit le type Repo précédent. Cependant on l’a fait dériver de Generic et on a demandé d’instancier les classes FromJSON et ToJSON. Grâce à DeriveGeneric et à Aeson, ces instances sont écrites automatiquement, en reprenant les champs du type Repo pour le format JSON.

On peut alors écrire un programme principal qui décode l’exemple de données JSON précédent.

main :: IO ()
main = do
    repoE <- eitherDecodeFileStrict "data.json"
    case repoE of
        Left err -> print err
        Right repo -> print (repo :: Repo)

Le programme importe bien les données JSON dans un type Repo en Haskell.

Repo {id = 1280014, name = "aeson", private = False, ...

Instanciation personnalisée

Ainsi, en ajoutant un deriving et deux instance, on peut avoir un type enregistrement compatible avec JSON mais Aeson fournit également d’autres fonctionnalités.

Par exemple, si on veut utiliser des champs différents, entre Haskell et JSON, on peut personnaliser les instances. Le fichier Owner.hs suivant définit un type Owner.

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}

module Owner where

import Data.Aeson
import Data.Text
import GHC.Generics

data Owner = Owner
    { mylogin :: Text
    , myid :: Int
    } deriving (Generic, Show)

Ici, le type Owner définit des champs mylogin et myid. Si on veut importer des données JSON où ces champs s’appellent différemment, par exemple login et id, on peut écrire explicitement notre instance de FromJSON.

instance FromJSON Owner where
    parseJSON = withObject "Owner" $ \v -> Owner
        <$> v .: "login"
        <*> v .: "id"

De même, pour exporter le type Owner vers un JSON dont les champs sont nommés différemment :

instance ToJSON Owner where
    toEncoding (Owner l i) = 
        pairs (  "login" .= l
              <> "id"    .= i
              )

Par exemple, avec le programme suivant :

main :: IO ()
main = C.putStrLn $ encode Owner { mylogin="haskell", myid=450574 }

On obtient :

{"login":"haskell","id":450574}

Conclusion

Les Generics de Haskell permettent de réaliser des fonctionnalités intéressantes, similaires à ce que propose la réflexion mais à la compilation. Par exemple, avec l’extension DeriveGeneric et la bibliothèque Aeson, on importer/exporter en JSON automatiquement, et, si besoin, personnaliser cet import/export.