Typer des URL avec Haskell Servant

Voir aussi : video youtube - code source

Servant est une bibliothèque Haskell qui permet de définir des URL au niveau des types. On peut ainsi bénéficier des vérifications du compilateur mais également générer des clients et de la documentation.

Exemple 1

On veut implémenter une API simple, avec deux routes. La route log retourne le logarithme d’un nombre donné (s’il existe) et la route add ajoute deux nombres x et y optionnels (0 par défaut). Par exemple, l’URL log/1 retournera 0.0 et l’URL add?x=20&y=22 retournera 42.

Typage d’URL

Servant permet de définir des routes par des types. Par exemple, la route log peut être définie par le type LogRoute suivant :

type LogRoute 
    = "log" 
    :> Capture "x" Double 
    :> Get '[JSON] (Maybe Double)

Ici, les deux premiers composants indiquent l’URL de base et le paramètre capturé. Le troisième composant indique la méthode HTTP (GET), le format de retourné (JSON) et le type des valeurs retournées (Maybe Double).

On définit la route add de la même façon, mais pour des paramètres d’URL optionnels.

type AddRoute 
    = "add" 
    :> QueryParam' '[] "x" Double 
    :> QueryParam' '[] "y" Double 
    :> Get '[JSON] Double

Les deux types LogRoute et AddRoute peuvent maintenant être utilisés, par exemple pour implémenter un serveur qui y répond ou un client qui y accède.

Serveur

Pour implémenter le serveur, on écrit tout d’abord l’API auquel il répond. Cette API est également implémentée par un type, par exemple en combinant les deux routes définies précédemment.

type ServerApi
    =    LogRoute
    :<|> AddRoute

On écrit ensuite une fonction qui va servir l’API, c’est-à-dire retourner les données correspondant aux différentes routes.

handleServerApi :: Server ServerApi
handleServerApi
    =    handleLogRoute
    :<|> handleAddRoute

Ici, on a découpé la gestion des deux routes en deux fonctions handleLogRoute et handleAddRoute :

handleLogRoute :: Double -> Handler (Maybe Double)
handleLogRoute x
    | x <= 0 = pure Nothing
    | otherwise = pure $ Just $ log x

handleAddRoute :: Maybe Double -> Maybe Double -> Handler Double
handleAddRoute mx my =
    let x = fromMaybe 0 mx
        y = fromMaybe 0 my
    in pure $ x+y

Enfin, il ne reste plus qu’à créer l’application serveur (en utilisant l’API et la fonction de gestion) et à lancer celle-ci dans la fonction principale :

serverApp :: Application
serverApp = serve (Proxy @ServerApi) handleServerApi

main :: IO ()
main = do
    let port = 3000
    putStrLn $ "listening on port: " ++ show port ++ "..."
    run port serverApp

On peut alors lancer le serveur et l’interroger via un client HTTP (navigateur web, curl…) :

$ curl "localhost:3000/log/1"
0.0

$ curl "localhost:3000/log/0"
null

$ curl "localhost:3000/add?x=22&y=20"
42.0

Client

À partir des types LogRoute et AddRoute, on peut également écrire un client. Par exemple, les deux fonctions suivantes interrogent directement les routes :

queryLog :: Double -> ClientM (Maybe Double)
queryLog = client (Proxy @LogRoute)

queryAdd :: Maybe Double -> Maybe Double -> ClientM Double
queryAdd = client (Proxy @AddRoute)

On peut également écrire des requêtes plus complexes :

queryLogAdd :: ClientM Double
queryLogAdd = do
    mx <- queryLog 0
    queryAdd mx (Just 42)

Et enfin, exécuter ces requêtes dans un programme principal :

main :: IO ()
main = do
    myManager <- newManager defaultManagerSettings
    let myClient = mkClientEnv myManager (BaseUrl Http "localhost" 3000 "")
    runClientM (queryLog 1) myClient >>= print
    runClientM (queryAdd Nothing (Just 42)) myClient >>= print
    runClientM queryLogAdd myClient >>= print

Résultat :

$ runghc Client.hs 
Right (Just 0.0)
Right 42.0
Right 42.0

Exemple 2

Dans ce second exemple, on veut implémenter un serveur qui fournit des informations sur des films.

Types personnalisés

Haskell permet de définir des types de données algébriques et de les convertir facilement au format JSON (voir le tuto 55). Par exemple, pour un type Film avec titre, réalisateur et année de sortie :

data Film = Film
    { _title :: Text
    , _director :: Text
    , _year :: Maybe Int
    } deriving (Generic, Show)

instance ToJSON Film

On peut alors écrire des routes qui retournent des Film au format JSON, par exemple d’un réalisateur donné :

type DirFilmsRoute = "dirFilms" :> Capture "director" Text :> Get '[JSON] [Film]

handleDirFilmsRoute :: Text -> Handler [Film]
handleDirFilmsRoute dir = pure $ filter ((==dir) . _director) myFilms

myFilms :: [Film]
myFilms =
    [ Film "First Blood" "Kotcheff" (Just 1982)
    , Film "Paths of Glory" "Kubrick" (Just 1957)
    , Film "Full Metal Jacket" "Kubrick" Nothing
    ]

Résultat :

$ curl "localhost:3000/dirFilms/Kotcheff"
[{"_year":1982,"_title":"First Blood","_director":"Kotcheff"}]

Formats de reponse

Servant permet d’indiquer le format de réponse des routes. Par exemple, le type suivant indique que la route fournit les formats JSON et HTML.

type AllFilmsRoute = "allFilms" :> Get '[HTML,JSON] [Film]

Bien entendu, le type de retour doit être compatible avec les formats indiqués. Ici, le format JSON est géré car Film instancie ToJSON. Pour générer aussi une page HTML correspondant à la route, il faut également instancier ToHTML :

instance ToHtml [Film] where
    toHtmlRaw = toHtml

    toHtml films = doctypehtml_ $ do
        body_ $ do
            h2_ "films"
            ul_ $ forM_ films $ \film -> li_ $ toHtml $ show film
            h2_ "routes"
            ul_ $ do
                li_ $ a_ [href_ (dirFilmsUrl "Kotcheff")] "dirFilmsUrl Kotcheff"
                li_ $ a_ [href_ (dirFilmsUrl "Kubrick")] "dirFilmsUrl Kubrick"

On notera qu’on a ici intancié directement le type [Film], pour simplifier, mais qu’il serait certainement plus propre de définir explicitement un type pour cette route/rendu.

Enfin, dirFilmsUrl est une fonction qui permet de générer une URL à partir du type de la route (ce qui est donc vérifié par le compilateur) :

dirFilmsUrl :: Text -> Text
dirFilmsUrl dir = toUrlPiece $ safeLink (Proxy @ServerApi) (Proxy @DirFilmsRoute) dir

Résultat :

Conclusion

Servant est une bibliothèque Haskell très populaire pour réaliser des API type-safe. Pour cela, elle permet d’écrire des routes sous forme de types, avec toutes les fonctionnalités classiques (méthodes HTTP, paramètres des URL, etc).

Une fois les routes typées, on peut écrire des clients qui y accèdent et des serveurs qui y répondent (avec différents formats comme JSON/HTML…). Et comme nos API sont définies au niveau des types, le compilateur les vérifie automatiquement, ce qui évite toute une classe d’erreurs.