Serving an API

Table of contents

型レベル結合子とAPIの種類についての説明はこのくらいにして、いよいよウェブサービスに進みましょう。

このガイドに書いてあることを試してみるには、下記をコマンドを入力します。

cabal get servant-examples
cd servant-examples-<VERSION>
cabal sandbox init
cabal install --dependencies-only
cabal configure && cabal build

上記のコマンドは tutorial 実行ファイルを dist/build/tutorial というパスに生成します。 コマンドライン引数に対応するサンプル番号を入れて実行します。

$ dist/build/tutorial/tutorial
Usage:   tutorial N
        where N is the number of the example you want to run.

A first example

APIを作る基礎知識は身につけたので、最初のウェブサービスを書いてみましょう。

この章のソースはliterate haskell fileで書いてあります。 いくつかの言語拡張とライブラリインポートが必要です。

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}

module Server where

import Control.Monad.IO.Class
import Control.Monad.Reader
import Control.Monad.Trans.Either
import Data.Aeson
import Data.Aeson.Types
import Data.Attoparsec.ByteString
import Data.ByteString (ByteString)
import Data.Int
import Data.List
import Data.String.Conversions
import Data.Time.Calendar
import GHC.Generics
import Lucid
import Network.HTTP.Media ((//), (/:))
import Network.Wai
import Network.Wai.Handler.Warp
import Servant
import System.Directory
import Text.Blaze
import Text.Blaze.Html.Renderer.Utf8
import qualified Data.Aeson.Parser
import qualified Text.Blaze.Html
{-# LANGUAGE TypeFamilies #-}

重要: Servant モジュールは servant-server パッケージにあります。 ウェブサーバを実行したり幾つかのAPIを実装しています。 Servant モジュールは servant パッケージからすべての型をエクスポートしなおしています。 リクエストハンドラをウェブサービスにするために必要なすべてのAPIが定義されています。 つまり、依存パッケージに servant-server を追加して Servant をインポートするだけで うまくいくので他の心配は要らないということです。

次のAPIからサーバを書きます。

type UserAPI1 = "users" :> Get '[JSON] [User]

これは /users へ GET リクエストを送ると何が見えるかを示しています。

[ {"name": "Isaac Newton", "age": 372, "email": "isaac@newton.co.uk", "registration_date": "1683-03-01"}
, {"name": "Albert Einstein", "age": 136, "email": "ae@mc2.org", "registration_date": "1905-12-01"}
]

User データ型を定義して、インスタンスを書いてみましょう。

data User = User
  { name :: String
  , age :: Int
  , email :: String
  , registration_date :: Day
  } deriving (Eq, Show, Generic)

instance ToJSON User

何も楽しくはありませんが、2人のユーザのリストを定義しましょう。

users1 :: [User]
users1 =
  [ User "Isaac Newton"    372 "isaac@newton.co.uk" (fromGregorian 1683  3 1)
  , User "Albert Einstein" 136 "ae@mc2.org"         (fromGregorian 1905 12 1)
  ]

APIの型も書きましょう。

type UserAPI1 = "users" :> Get '[JSON] [User]

このようなAPIに対するリクエストを処理するウェブサービスを実際に書いてみましょう。このAPIは 非常に単純で、1つのエンドポイントに絞られています。ウェブアプリケーションの型は Server という type family を通して API の型から決まります。(type family は入力と戻り値の型を 型として持つ関数です。) Server type family は大量のリスエストハンドラが持つ正しい型を 対応するAPIの型から判別してくれます。

Server type family について知るべきことは、それが裏でひっそりとルーティングを制御 しているので、ビジネスロジックに専念できるということです。また、個々のエンドポイントごと にハンドラがデフォルトでは EitherT ServantErr IO モナド内で動きます。モナド内で返る 値の型は、対応するエンドポイント使われるHTTPメソッド結合子の二番目の引数と同じでなければ なりません。前述のAPIの場合、EitherT ServantErr IO [User] 型のハンドラを用意しなけ ればなりません。モナドが手に入ったので、ユーザ一覧を返してみましょう。

server1 :: Server UserAPI1
server1 = return users1

よくできました。 waiwarp を使えば、server を実際のウェブサーバにすることができます。

userAPI :: Proxy UserAPI1
userAPI = Proxy

-- 'serve' は servant から与えられたもので WAI アプリケーションと接続します。
-- "抽象的な" ウェブアプリケーションであってウェブサーバのことではありません。
app1 :: Application
app1 = serve userAPI server1

userAPI は悲しいかな、ボイラープレートです。(型推論の助けが必要です) しかし、これ以外にボイラープレートはありません。

これで 8081 ポートで動くウェブサービスができました!

main :: IO ()
main = run 8081 app1

これを1つのファイルに書くか、servant’s repo を取ってきて servant-examples ディレクトリを見てみましょう。コードは tutorial/T1.hs に あります。dist/build/tutorial/tutorial 1 コマンドで動作します。

もし動かせたら、ウェブブラウザか curl で http://localhost:8081/users を見てみましょう。

$ curl http://localhost:8081/users
[{"email":"isaac@newton.co.uk","registration_date":"1683-03-01","age":372,"name":"Isaac Newton"},{"email":"ae@mc2.org","registration_date":"1905-12-01","age":136,"name":"Albert Einstein"}]

More endpoints

1つ以上のエンドポイントが必要な時は次のようにしましょう。JSONエンコードされたユーザ情報を見るために /albert/isaac を追加します。

type UserAPI2 = "users" :> Get '[JSON] [User]
           :<|> "albert" :> Get '[JSON] User
           :<|> "isaac" :> Get '[JSON] User

それに合わせて、コードも少し直します。

isaac :: User
isaac = User "Isaac Newton" 372 "isaac@newton.co.uk" (fromGregorian 1683 3 1)

albert :: User
albert = User "Albert Einstein" 136 "ae@mc2.org" (fromGregorian 1905 12 1)

users2 :: [User]
users2 = [isaac, albert]

UserAPI の中で :<|> を使ってエンドポイントを分けたように、ハンドラも :<|> を使って分けます。 ただし、API の type で宣言したのと同じ順番で並べなければなりません。

server2 :: Server UserAPI2
server2 = return users2
     :<|> return albert
     :<|> return isaac

これで完成です! この例題は dist/build/tutorial/tutorial 2 で実行できます。 /users, /albert, /isaac で確認してみましょう。

From combinators to handler arguments

それで、簡単にちょっとしたウェブサービスを書けるようになったが、上記の2つはどちらも servant にある “変な”結合子は使っていない。とにかく QueryParam, Capture, ReqBody を使ってみよう。 この項目では、エンドポイントにあるこれらの結合子が、どのようにして対応するハンドラに適切な型を持つ引数を 自動的に受け取らせるかが書いてあります。URLキャプチャやクエリストリングパラメータ、JSONへのデータ変換 などを調べる手間を心配するは必要はありません。心配無用です。

以下のデータ型と関数を使ってサーバの API を実装しましょう。

type API = "position" :> Capture "x" Int :> Capture "y" Int :> Get '[JSON] Position
      :<|> "hello" :> QueryParam "name" String :> Get '[JSON] HelloMessage
      :<|> "marketing" :> ReqBody '[JSON] ClientInfo :> Post '[JSON] Email

data Position = Position
  { x :: Int
  , y :: Int
  } deriving Generic

instance ToJSON Position

newtype HelloMessage = HelloMessage { msg :: String }
  deriving Generic

instance ToJSON HelloMessage

data ClientInfo = ClientInfo
  { clientName :: String
  , clientEmail :: String
  , clientAge :: Int
  , clientInterestedIn :: [String]
  } deriving Generic

instance FromJSON ClientInfo
instance ToJSON ClientInfo

data Email = Email
  { from :: String
  , to :: String
  , subject :: String
  , body :: String
  } deriving Generic

instance ToJSON Email

emailForClient :: ClientInfo -> Email
emailForClient c = Email from' to' subject' body'

  where from'    = "great@company.com"
        to'      = clientEmail c
        subject' = "Hey " ++ clientName c ++ ", we miss you!"
        body'    = "Hi " ++ clientName c ++ ",\n\n"
                ++ "Since you've recently turned " ++ show (clientAge c)
                ++ ", have you checked out our latest "
                ++ intercalate ", " (clientInterestedIn c)
                ++ " products? Give us a visit!"

ハンドラの3つのエンドポイントを実装します。

server3 :: Server API
server3 = position
     :<|> hello
     :<|> marketing

  where position :: Int -> Int -> EitherT ServantErr IO Position
        position x y = return (Position x y)

        hello :: Maybe String -> EitherT ServantErr IO HelloMessage
        hello mname = return . HelloMessage $ case mname of
          Nothing -> "Hello, anonymous coward"
          Just n  -> "Hello, " ++ n

        marketing :: ClientInfo -> EitherT ServantErr IO Email
        marketing clientinfo = return (emailForClient clientinfo)

上記を見てください。ハンドラの型は必要な形に変わっています。 特に注目すべきは以下の箇所です。

以上です。 この例題は dist/build/tutorial/tutorial 3 で実行できます。

$ curl http://localhost:8081/position/1/2
{"x":1,"y":2}
$ curl http://localhost:8081/hello
{"msg":"Hello, anonymous coward"}
$ curl http://localhost:8081/hello?name=Alp
{"msg":"Hello, Alp"}
$ curl -X POST -d '{"name":"Alp Mestanogullari", "email" : "alp@foo.com", "age": 25, "interested_in": ["haskell", "mathematics"]}' -H 'Accept: application/json' -H 'Content-type: application/json' http://localhost:8081/marketing
{"subject":"Hey Alp Mestanogullari, we miss you!","body":"Hi Alp Mestanogullari,\n\nSince you've recently turned 25, have you checked out our latest haskell, mathematics products? Give us a visit!","to":"alp@foo.com","from":"great@company.com"}

参考までに、servant の結合子の一覧、引数からハンドラへの変換、引数の型についてまとめました。

  • Delete, Get, Patch, Post, Put: これらは引数にはならず、ハンドラの戻り値の型を示します。通常は EitherT ServantErr IO <something> のように表されます。
  • Capture "something" aa 型の引数となります.
  • QueryParam "something" a, MatrixParam "something" a, Header "something" a はすべて Maybe a 型の引数となります。クライアントから渡される値がなくても大丈夫です。
  • QueryFlag "something"MatrixFlag "something"Bool 型の引数になります.
  • QueryParams "something" aMatrixParams "something" a[a] 型の引数となります。
  • ReqBody contentTypes aa 型の引数となります.

The FromText/ToText classes

servant は URL から Int にデコードしたり、リクエストボディから ClientInfo の値をデコードしたりする 方法をどうやって知るのでしょうか? この章と次の章でそれが明らかになります。

CaptureQueryParam は URL 内にテキストで書かれています。Header はリクエストの メタデータ内にヘッダ名と値が同じようにテキストで書かれています。servant で FromTextToText という2つの型クラスが提供されている理由は、これら各々のテキストから値または値からテキストへと変換できる ようにするためです。定義は以下のとおりです:

class FromText a where
  fromText :: Text -> Maybe a

class ToText a where
  toText :: a -> Text

CaptureQueryParam, Header などは FromText インスタンスを提供してあげれば、 正しく動作します。servant は十分な数のインスタンスを提供していますが、自分で定義する場合の 例を以下に示します。

-- 典型的な enumeration
data Direction
  = Up
  | Down
  | Left
  | Right

instance FromText Direction where
  -- {-# LANGUAGE OverloadedStrings #-} が必要
  fromText "up"    = Just Up
  fromText "down"  = Just Down
  fromText "left"  = Just Server.Left
  fromText "right" = Just Server.Right
  fromText       _ = Nothing

instance ToText Direction where
  toText Up           = "up"
  toText Down         = "down"
  toText Server.Left  = "left"
  toText Server.Right = "right"

newtype UserId = UserId Int64
  deriving (FromText, ToText)

もしくは以下のようにも書けます。

instance FromText UserId where
  fromText = fmap UserId fromText

instance ToText UserId where
  toText (UserId i) = toText i

これらの class について言うことは他に何もありません。Capture, QueryParam, QueryParams, MatrixParam, MatrixParams, Header そして自作の型を使う時にはそれらのインスタンスが必要です。 FromText インスタンスはサーバサイドで、ToText インスタンスは servant-client でのみ使います。 servant-client については section about deriving haskell functions to query an API に書きます。

Using content-types with your data types

JSON からリクエストボディをデコードするまたはレスポンスから JSON をエンコードするときにも 同じ原則が使われます (JSON は実行可能な例題で、任意の content-type で使うことができます)

この章では servant が提供する2つの型クラスを紹介します。

The truth behind JSON

JSON とは一体何でしょうか? JSON は servant が提供する他の2つの content-type と同じように ダンプデータ型です。

data JSON
data PlainText
data FormUrlEncoded
data OctetStream

明らかにこれは JSON になるためのすべてではないし、全く要領を得ないとも言える。servant 内の 多くのデータ型と同様に、JSON は JSON フォーマットへエンコードする特別なシンボルでもあります。 この関係をつなぐ方法は2つのステップに分けられます。

初めのステップは JSON または自前の content-type を表すのに適した MediaType を提供することです。このリンクから haddock を生成するには、適切な関数を使って application/json を 指定しなければなりません。この場合には、(//) :: ByteString -> ByteString -> MediaType を 使いましょう。正しく MediaType を指定するには、Accept クラスのインスタンスを書きましょう。

-- for reference:
class Accept ctype where
    contentType   :: Proxy ctype -> MediaType

instance Accept JSON where
    contentType _ = "application" // "json"

2番目のステップは MimeRenderMimeUnrender クラスを中心としたことです。 これらのクラスは値を自前の content-type に変換する方法を示します。

class Accept ctype => MimeRender ctype a where
    mimeRender  :: Proxy ctype -> a -> ByteString
    -- alternatively readable as:
    mimeRender  :: Proxy ctype -> (a -> ByteString)

content-type と user type を与えると、MimeRendera型の値を遅延ByteString型 にエンコードする機能を提供します。

JSON の場合は簡単です。任意の a 型に対する ToJSON インスタンスにおいて、 Data.Aeson.encode を使うとその型の値を JSON に変換できます。

instance ToJSON a => MimeRender JSON a where
  mimeRender _ = encode

MimeUnrender クラスを使うと、遅延ByteStringから値を抽出できる、もしくは エラー文字列を出して失敗します。

class Accept ctype => MimeUnrender ctype a where
    mimeUnrender :: Proxy ctype -> ByteString -> Either String a
    -- alternatively:
    mimeUnrender :: Proxy ctype -> (ByteString -> Either String a)

Data.Aeson.eitherDecode を使えば either についてすることはほとんどありません。 しかし、トップレベル JSON の値として配列かオブジェクトしか許されておらず、“JSONドキュメント” のトップレベルで任意の型のJSONの値を使うことができる aesonattoparsec を使って ちょっとした関数を書くほうがよく使われています。以下に定義を示します。

eitherDecodeLenient :: FromJSON a => ByteString -> Either String a
eitherDecodeLenient input = do
    v :: Value <- parseOnly (Data.Aeson.Parser.value <* endOfInput) (cs input)
    parseEither parseJSON v

以下の関数はまさに MimeUnrender インスタンスに必要なものです。

instance FromJSON a => MimeUnrender JSON a where
    mimeUnrender _ = eitherDecodeLenient

ReqBody, Get, Post そして友達のために JSON を使うためのすべてのコードができました。 どのくらい理解が進んだかを確認するために HTML content-type に対応する実装をしてみましょう。 そして、そのウェブサービスのユーザが HTML データにアクセスできて、HTMLドキュメントをインクルード できる準備ができているかは、例えば jQuery’s load function を使ったり、リクエストヘッダに Accept: text/html を追加すれば確認できます。

Case-studies: servant-blaze and servant-lucid

Haskell から直接 HTML UI を書くには、最近では blaze-htmllucid が使われています。 servant ではどちらもサポートされています。

data HTMLLucid

改めて書きますが、データ型は関数を変換する記法です。blaze-htmllucid は HTML から データを抽出する方法を提供しないので、今回はエンコードについて心配しないことにします。

両方のパッケージともに同じく HTMLLucid 型の Accept インスタンスを持ちます。

instance Accept HTMLLucid where
    contentType _ = "text" // "html" /: ("charset", "utf-8")

注目すべきは、このインスタンスは http-media にある (/:) 演算子を使っていることです。 この演算子は、上記の charset のような content-type についての追加情報を示します。

両方のパッケージでレンダリングインスタンスは似たような関数を呼び出す。その関数は適切な インスタンスで型を“抽象的な”HTML表現に変え、ByteStringで書き出す。

lucid の場合:

instance ToHtml a => MimeRender HTMLLucid a where
    mimeRender _ = renderBS . toHtml

-- let's also provide an instance for lucid's
-- 'Html' wrapper.
instance MimeRender HTMLLucid (Html a) where
    mimeRender _ = renderBS

blaze-html の場合:

-- For this tutorial to compile 'HTMLLucid' and 'HTMLBlaze' have to be
-- distinct. Usually you would stick to one html rendering library and then
-- you can go with one 'HTML' type.
data HTMLBlaze

instance Accept HTMLBlaze where
    contentType _ = "text" // "html" /: ("charset", "utf-8")

instance ToMarkup a => MimeRender HTMLBlaze a where
    mimeRender _ = renderHtml . Text.Blaze.Html.toHtml

-- while we're at it, just like for lucid we can
-- provide an instance for rendering blaze's 'Html' type
instance MimeRender HTMLBlaze Text.Blaze.Html.Html where
    mimeRender _ = renderHtml

適切なクラス(blaze-html では ToMarkuplucid では ToHtml)のインスタンスを 用意できる限り、servant-blazeservant-lucid の両方ともが content-type リストで HTMLLucid を使えます。

HTMLLucid を表示するために servant-lucid を使うウェブサービスを書いてみましょう。 まずは import や pragma などを書きます。

次の API を用意します。

type PersonAPI = "persons" :> Get '[JSON, HTMLLucid] [Person]

Person は以下のように定義します。

data Person = Person
  { firstName :: String
  , lastName  :: String
  } deriving Generic -- for the JSON instance

instance ToJSON Person

lucidPerson をテーブルの列として描画する方法を示しましょう。そして、Person のリストは person ごとの列として扱います。

-- HTML serialization of a single person
instance ToHtml Person where
  toHtml person =
    tr_ $ do
      td_ (toHtml $ firstName person)
      td_ (toHtml $ lastName person)

  -- do not worry too much about this
  toHtmlRaw = toHtml

-- HTML serialization of a list of persons
instance ToHtml [Person] where
  toHtml persons = table_ $ do
    tr_ $ do
      th_ "first name"
      th_ "last name"

    -- this just calls toHtml on each person of the list
    -- and concatenates the resulting pieces of HTML together
    foldMap toHtml persons

  toHtmlRaw = toHtml

Person の値を作り、その一覧をサーバで扱いましょう。

persons :: [Person]
persons =
  [ Person "Isaac"  "Newton"
  , Person "Albert" "Einstein"
  ]

personAPI :: Proxy PersonAPI
personAPI = Proxy

server4 :: Server PersonAPI
server4 = return persons

app2 :: Application
app2 = serve personAPI server4

これで大丈夫です。 この例題は dist/build/tutorial/tutorial 4 で実行できます。

 $ curl http://localhost:8081/persons
 [{"lastName":"Newton","firstName":"Isaac"},{"lastName":"Einstein","firstName":"Albert"}]
 $ curl -H 'Accept: text/html' http://localhost:8081/persons
 <table><tr><td>first name</td><td>last name</td></tr><tr><td>Isaac</td><td>Newton</td></tr><tr><td>Albert</td><td>Einstein</td></tr></table>
 # or just point your browser to http://localhost:8081/persons

The EitherT ServantErr IO monad

ハンドラの中心には EitherT ServantErr IO モナドがあります。1つ不思議な事は、どうしてこのモナド なのでしょうか? 答えは、以下の性質を持つ最も単純なモナドだから、です。

定義を思い出してみましょう。

-- from the Prelude
data Either e a = Left e | Right a

-- from the 'either' package at
-- http://hackage.haskell.org/package/either-4.3.3.2/docs/Control-Monad-Trans-Either.html
newtype EitherT e m a
  = EitherT { runEitherT :: m (Either e a) }

要するに、EitherT ServantErr IO a 型のハンドラは IO (Either ServantErr a) 型を操作したもの と単に等価であることを意味しています。これはつまりエラーか結果を返す IO アクションです。

前述の either パッケージは見る価値があります。以下も大事なことです。

left :: Monad m => e -> EitherT e m a

これを使うとハンドラからエラーを返せます。

ハンドラで処理しようとしていることの多くは IO を動かすことで、その結果によってエラーを返したり、 処理を中断したりしたいと思うかもしれません。次の2つの章ではこの方法を示します。

Performing IO

上述のうちのもう1つの大事なインスタンスは、MonadIO m => MonadIO (EitherT e m) です。 MonadIO は、transformers パッケージにあるクラスで、以下のように定義されています。

class Monad m => MonadIO m where
  liftIO :: IO a -> m a

明らかに、IO モナドは MonadIO インスタンスを提供しています。 したがって、任意の e 型において、EitherT e IOMonadIO を持っています。 ハンドラ内で IO 操作する任意の kind を実行したければ、liftIO を使いましょう。

type IOAPI1 = "myfile.txt" :> Get '[JSON] FileContent

newtype FileContent = FileContent
  { content :: String }
  deriving Generic

instance ToJSON FileContent

server5 :: Server IOAPI1
server5 = do
  filecontent <- liftIO (readFile "myfile.txt")
  return (FileContent filecontent)

Failing, through ServantErr

もし適切なHTTPステータスコードとエラーメッセージを使ってエンドポイントで決められた結果を 明示的に失敗させたいのであれば、上述の left 関数を使うか、ServantErr 型の適切な値を 生成しましょう。ServantErr は以下のように定義されています。

data ServantErr = ServantErr
    { errHTTPCode     :: Int
    , errReasonPhrase :: String
    , errBody         :: ByteString -- lazy bytestring
    , errHeaders      :: [Header]
    }

多くの標準的な値は、Servant.Server モジュールで提供されています。もしこれらの値を使うけど 一部を変更したい場合には、以下の record update syntax を使いましょう。

failingHandler :: EitherT ServantErr IO ()
failingHandler = left myerr

  where myerr :: ServantErr
        myerr = err503 { errBody = "Sorry dear user." }

以下の例は、“myfile.txt” が存在しなかった場合にレスポンスボディに含まれるカスタマイズされた 404-Not-Found エラーメッセージです。

server6 :: Server IOAPI1
server6 = do
  exists <- liftIO (doesFileExist "myfile.txt")
  if exists
    then liftIO (readFile "myfile.txt") >>= return . FileContent
    else left custom404Err

  where custom404Err = err404 { errBody = "myfile.txt just isn't there, please leave this server alone." }

dist/build/tutorial/tutorial 5 でこのサーバを動かせます。 初めはファイルが存在しない状態でクエリを送ってみましょう。

 $ curl --verbose http://localhost:8081/myfile.txt
 [snip]
 * Connected to localhost (127.0.0.1) port 8081 (#0)
 > GET /myfile.txt HTTP/1.1
 > User-Agent: curl/7.30.0
 > Host: localhost:8081
 > Accept: */*
 >
 < HTTP/1.1 404 Not Found
 [snip]
 myfile.txt just isnt there, please leave this server alone.

 $ echo Hello > myfile.txt

 $ curl --verbose http://localhost:8081/myfile.txt
 [snip]
 * Connected to localhost (127.0.0.1) port 8081 (#0)
 > GET /myfile.txt HTTP/1.1
 > User-Agent: curl/7.30.0
 > Host: localhost:8081
 > Accept: */*
 >
 < HTTP/1.1 200 OK
 [snip]
 < Content-Type: application/json
 [snip]
 {"content":"Hello\n"}

Response headers

レスポンスにヘッダを加えるためには addHeader を使います。以下の例のように API の型が変わることに注意しましょう。

type MyHandler = Get '[JSON] (Headers '[Header "X-An-Int" Int] User)

myHandler :: Server MyHandler
myHandler = return $ addHeader 1797 albert

Serving static files

servant-server は Web API 内でのあるパス以下のディレクトリのコンテンツをサーブする方法も提供します。 Raw 結合子は任意の WAI アプリケーションをつなぐ API で使われます。servant-server は WAI アプリを サーブするのにファイルやディレクトリを取得する機能を提供すます。

-- exported by Servant and Servant.Server
serveDirectory :: FilePath -> Server Raw

serveDirectory の引数は有効なディレクトリへのパスでなければなりません。 以下に例を示します。これは dist/build/tutorial/tutorial 6 でこのサーバを動かせます。 (必ず servant-examples/ ディレクトリで動かさなければなりません) このチュートリアルで網羅されている大量のコードを動かすウェブサーバです。

API の定義は次のようになります。

type CodeAPI = "code" :> Raw

サーバ

codeAPI :: Proxy CodeAPI
codeAPI = Proxy
server7 :: Server CodeAPI
server7 = serveDirectory "tutorial"

app3 :: Application
app3 = serve codeAPI server7

このサーバは /code でパスが始まる任意のリクエストにマッチします。 そしてこのプログラムを動かしているパスの tutorial/ ディレクトリの中で、 残りのリクエストパスで指定されるパスでファイルを探します。

これは以下のようにも言えます。

これが実際に動くサーバです。

$ curl http://localhost:8081/code/T1.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeOperators #-}
module T1 where

import Data.Aeson
import Data.Time.Calendar
import GHC.Generics
import Network.Wai
import Servant

data User = User
  { name :: String
  , age :: Int
  , email :: String
  , registration_date :: Day
  } deriving (Eq, Show, Generic)

-- orphan ToJSON instance for Day. necessary to derive one for User
instance ToJSON Day where
  -- display a day in YYYY-mm-dd format
  toJSON d = toJSON (showGregorian d)

instance ToJSON User

type UserAPI = "users" :> Get '[JSON] [User]

users :: [User]
users =
  [ User "Isaac Newton"    372 "isaac@newton.co.uk" (fromGregorian 1683  3 1)
  , User "Albert Einstein" 136 "ae@mc2.org"         (fromGregorian 1905 12 1)
  ]

userAPI :: Proxy UserAPI
userAPI = Proxy

server :: Server UserAPI
server = return users

app :: Application
app = serve userAPI server
$ curl http://localhost:8081/code/tutorial.hs
import Network.Wai
import Network.Wai.Handler.Warp
import System.Environment

import qualified T1
import qualified T2
import qualified T3
import qualified T4
import qualified T5
import qualified T6
import qualified T7
import qualified T9
import qualified T10

app :: String -> (Application -> IO ()) -> IO ()
app n f = case n of
  "1" -> f T1.app
  "2" -> f T2.app
  "3" -> f T3.app
  "4" -> f T4.app
  "5" -> f T5.app
  "6" -> f T6.app
  "7" -> f T7.app
  "8" -> f T3.app
  "9" -> T9.writeJSFiles >> f T9.app
  "10" -> f T10.app
  _   -> usage

main :: IO ()
main = do
  args <- getArgs
  case args of
    [n] -> app n (run 8081)
    _   -> usage

usage :: IO ()
usage = do
  putStrLn "Usage:\t tutorial N"
  putStrLn "\t\twhere N is the number of the example you want to run."

$ curl http://localhost:8081/foo
not found

Nested APIs

重複を避けて、組み立て式の方法でどうやってAPIを定義することができるか見てみましょう。 シンプルな例を考えます。

type UserAPI3 = -- view the user with given userid, in JSON
                Capture "userid" Int :> Get '[JSON] User

           :<|> -- delete the user with given userid. empty response
                Capture "userid" Int :> Delete '[] ()

むしろ userid をくくりだすことができます。

type UserAPI4 = Capture "userid" Int :>
  (    Get '[JSON] User
  :<|> Delete '[] ()
  )

しかし、対応する Server の型に影響することには注意しなければなりません。

Server UserAPI3 = (Int -> EitherT ServantErr IO User)
             :<|> (Int -> EitherT ServantErr IO ())

Server UserAPI4 = Int -> (    EitherT ServantErr IO User
                         :<|> EitherT ServantErr IO ()
                         )

前者の場合には、ハンドラごとに userid 引数を取ります。 後者の場合には、Server 全体が userid を取り、引数無しで EitherT を処理するハンドラを持ちます。 以下のようにも書けます。

server8 :: Server UserAPI3
server8 = getUser :<|> deleteUser

  where getUser :: Int -> EitherT ServantErr IO User
        getUser _userid = error "..."

        deleteUser :: Int -> EitherT ServantErr IO ()
        deleteUser _userid = error "..."

-- notice how getUser and deleteUser
-- have a different type! no argument anymore,
-- the argument directly goes to the whole Server
server9 :: Server UserAPI4
server9 userid = getUser userid :<|> deleteUser userid

  where getUser :: Int -> EitherT ServantErr IO User
        getUser = error "..."

        deleteUser :: Int -> EitherT ServantErr IO ()
        deleteUser = error "..."

くくりだせる Capture は何も特別でなく、任意の結合子に対して可能であることに注目しましょう。 くくりだせる結合子を使ったいくつかの API の例を示します。 これを使えば完全に有効に機能するサーバを書くことができます。

-- we just factor out the "users" path fragment
type API1 = "users" :>
  (    Get '[JSON] [User] -- user listing
  :<|> Capture "userid" Int :> Get '[JSON] User -- view a particular user
  )

-- we factor out the Request Body
type API2 = ReqBody '[JSON] User :>
  (    Get '[JSON] User -- just display the same user back, don't register it
  :<|> Post '[JSON] ()  -- register the user. empty response
  )

-- we factor out a Header
type API3 = Header "Authorization" Token :>
  (    Get '[JSON] SecretData -- get some secret data, if authorized
  :<|> ReqBody '[JSON] SecretData :> Post '[] () -- add some secret data, if authorized
  )

newtype Token = Token ByteString
newtype SecretData = SecretData ByteString

このアプローチでモジュール構造のAPIを定義できます。 最終的に巨大な API type として組み立てることができます。

type UsersAPI =
       Get '[JSON] [User] -- list users
  :<|> ReqBody '[JSON] User :> Post '[] () -- add a user
  :<|> Capture "userid" Int :>
         ( Get '[JSON] User -- view a user
      :<|> ReqBody '[JSON] User :> Put '[] () -- update a user
      :<|> Delete '[] () -- delete a user
         )

usersServer :: Server UsersAPI
usersServer = getUsers :<|> newUser :<|> userOperations

  where getUsers :: EitherT ServantErr IO [User]
        getUsers = error "..."

        newUser :: User -> EitherT ServantErr IO ()
        newUser = error "..."

        userOperations userid =
          viewUser userid :<|> updateUser userid :<|> deleteUser userid

          where
            viewUser :: Int -> EitherT ServantErr IO User
            viewUser = error "..."

            updateUser :: Int -> User -> EitherT ServantErr IO ()
            updateUser = error "..."

            deleteUser :: Int -> EitherT ServantErr IO ()
            deleteUser = error "..."
type ProductsAPI =
       Get '[JSON] [Product] -- list products
  :<|> ReqBody '[JSON] Product :> Post '[] () -- add a product
  :<|> Capture "productid" Int :>
         ( Get '[JSON] Product -- view a product
      :<|> ReqBody '[JSON] Product :> Put '[] () -- update a product
      :<|> Delete '[] () -- delete a product
         )

data Product = Product { productId :: Int }

productsServer :: Server ProductsAPI
productsServer = getProducts :<|> newProduct :<|> productOperations

  where getProducts :: EitherT ServantErr IO [Product]
        getProducts = error "..."

        newProduct :: Product -> EitherT ServantErr IO ()
        newProduct = error "..."

        productOperations productid =
          viewProduct productid :<|> updateProduct productid :<|> deleteProduct productid

          where
            viewProduct :: Int -> EitherT ServantErr IO Product
            viewProduct = error "..."

            updateProduct :: Int -> Product -> EitherT ServantErr IO ()
            updateProduct = error "..."

            deleteProduct :: Int -> EitherT ServantErr IO ()
            deleteProduct = error "..."
type CombinedAPI = "users" :> UsersAPI
              :<|> "products" :> ProductsAPI

server10 :: Server CombinedAPI
server10 = usersServer :<|> productsServer

最後に、user と product API がほとんど同じなので、1つにまとめることができます。

-- API for values of type 'a'
-- indexed by values of type 'i'
type APIFor a i =
       Get '[JSON] [a] -- list 'a's
  :<|> ReqBody '[JSON] a :> Post '[] () -- add an 'a'
  :<|> Capture "id" i :>
         ( Get '[JSON] a -- view an 'a' given its "identifier" of type 'i'
      :<|> ReqBody '[JSON] a :> Put '[] () -- update an 'a'
      :<|> Delete '[] () -- delete an 'a'
         )

-- Build the appropriate 'Server'
-- given the handlers of the right type.
serverFor :: EitherT ServantErr IO [a] -- handler for listing of 'a's
          -> (a -> EitherT ServantErr IO ()) -- handler for adding an 'a'
          -> (i -> EitherT ServantErr IO a) -- handler for viewing an 'a' given its identifier of type 'i'
          -> (i -> a -> EitherT ServantErr IO ()) -- updating an 'a' with given id
          -> (i -> EitherT ServantErr IO ()) -- deleting an 'a' given its id
          -> Server (APIFor a i)
serverFor = error "..."
-- implementation left as an exercise. contact us on IRC
-- or the mailing list if you get stuck!

Using another monad for your handlers

Server が HTTP メソッドの結合子をどのように EitherT ServantErr IO に変えたのかを思い出してみましょう。 Server は実際には単純な type synonym に過ぎません。

type Server api = ServerT api (EitherT ServantErr IO)

ServerTHasServer クラスの一部のハンドラが必要な型を算出する type family です。これは3番目のパラメータ を取ることを除けば Server のようなものです。このパラメータはハンドラを差し込みたいモナドもしくはハンドラが返す型 です。3番目のパラメータはエンドポイントのハンドラが返す型を特定するのに使います。 ServerT (Get '[JSON] Person) SomeMonad を処理すると、SomeMonad Person を返します。

最初の、そして主な疑問はどうしてこれが必要なのでしょうか。他のモナドに差し込むハンドラをどのように書けばいいのでしょうか。

Natural transformations

もし任意の a において m a から n a へ変換できる関数があったとすると、その関数はどういうもの になるのでしょうか。

newtype m :~> n = Nat { unNat :: forall a. m a -> n a}

-- For example
-- listToMaybeNat ::`[] :~> Maybe`
-- listToMaybeNat = Nat listToMaybe  -- from Data.Maybe

(Nat は “natural transformation” のことで、今回取り扱うものです。)

もし EitherT ServantErr IO とは別のモナド(Reader String モナド)または型を使ってハンドラを 書きたいのであれば、最初に用意すべきなのは以下の関数です。

readerToEither :: Reader String :~> EitherT ServantErr IO

readerToEither' から始めましょう。例えば “hi” のような String をそれに供給する処理を Reader にさせなければならないことは明らかです。a を取り出して EitherT の中で return しましょう。変わった型を持たせるために Nat コンストラクタを使った関数でラップします。

readerToEither' :: forall a. Reader String a -> EitherT ServantErr IO a
readerToEither' r = return (runReader r "hi")

readerToEither :: Reader String :~> EitherT ServantErr IO
readerToEither = Nat readerToEither'

Reader String を差し込みハンドラを使って単純なウェブサービスを書いてみましょう。

type ReaderAPI = "a" :> Get '[JSON] Int
            :<|> "b" :> Get '[JSON] String

readerAPI :: Proxy ReaderAPI
readerAPI = Proxy

readerServerT :: ServerT ReaderAPI (Reader String)
readerServerT = a :<|> b

  where a :: Reader String Int
        a = return 1797

        b :: Reader String String
        b = ask

運の悪いことに serve の引数として readerServerT を使うことはできません。serve の 引数として使えるのは Server ReaderAPI です。これは EitherT ServantErr IO を差し込む ハンドラで使います。それでも上記は単純な解決方法の1つです。

Enter enter

上で書いてきた readerToEither ですが、serve の正しい型を持つハンドラを作るために すべてのハンドラの戻り値にこれを正確に適用する必要があります。これを手動でやるのは面倒なので、 enter 関数が用意されています。これは mn という2つのパラメータの型と ServerT someapi m の間の natural transformation を行い、ServerT someapi n を返します。

具体的には、ハンドラ上で enter readerToEither を使ってウェブサービスをラップします。

readerServer :: Server ReaderAPI
readerServer = enter readerToEither readerServerT

app4 :: Application
app4 = serve readerAPI readerServer

dist/build/tutorial/tutorial 7 でこのウェブサービスを実行できます。

$ curl http://localhost:8081/a
1797
$ curl http://localhost:8081/b
"hi"

Conclusion

servant を使ってウェブサービス・ウェブアプリケーションを実装できるようになりました。 ここでは唯一、自分の結合子を組み込むのを取り扱いませんでしたが、このウェブサイトの別ページで扱っています。 残りの文章では servant-clientservant-jqueryservant-docs について取り扱います。

Previous page: A web API as a type

Next page: Deriving Haskell functions to query an API