読者です 読者をやめる 読者になる 読者になる

haskell-servant の利用例とちょっとだけ仕組みの調査

haskell

はじめに

Haskell には大小様々な Web フレームワークがあります.(yesod, scotty, spock, apiary, rest, 等々)

API サーバを作りたいときは scotty を利用することが多かったのですが,つい最近 haskell-servant というパッケージ群を知りました.

小さな API サーバを書きたいときに便利そうだなと思い,使いつつ軽くコードを読んでみました*1

環境

  • GHC 7.8.3 (64bit)

haskell-servant

型を用いて API 仕様を定義するタイプのフレームワークで,クライアントコードやドキュメントも生成してくれます. API の型とそれに対応するハンドラを組み合わせることで WAI アプリケーションとして動作します.

現在の形になったのはつい最近なんでしょうか? → Rethinking webservice and APIs in Haskell: servant 0.2

試作: メモアプリ用 API サーバ

以下のような API を定義しています.

type MemoAPI =
         "memos"                     :> Get [Memo]
    :<|> "memos" :> ReqBody ReqMemo  :> Post Memo
    :<|> "memos" :> Capture "id" Int :> Delete

:>/ みたいなもので,:<|>API の結合だそうです. route を構成するパスやパラメータ名もリテラルとして型に埋め込めます.

戻り値の型は ToJSONReqBody に指定する型は FromJSONCapture に指定する型は FromText をそれぞれ実装する必要があります.

例えば FromText であれば以下のようになります.

-- 例: 数値の offset を TimeZone として解釈させたいとき
import Data.Time
import Data.Text.Read  (decimal)

instance FromText TimeZone where
    fromText t =
        case decimal t of
            Right (h, _) -> Just $ hoursToTimeZone h
            _            -> Nothing

また,ドキュメント生成時にパラメータの説明やサンプルデータが必要になるため,それぞれ ToCaptureToSampleインスタンスを実装します.

instance ToCapture (Capture "id" Int) where
    toCapture _ = DocCapture "id" "memo id"

instance ToSample Memo where
    toSample = Just sampleMemo

instance ToSample [Memo] where
    toSample = Just [sampleMemo]

sampleMemo :: Memo
sampleMemo = Memo
    { id = 5
    , text = "Try haskell-servant."
    , createdAt = ZonedTime (LocalTime (fromGregorian 2014 12 31) midday) (hoursToTimeZone 9)
    }

仕様変更したときに何か不足していればコンパイルエラーになるため「実装とドキュメントがずれてしまう」といったことは減らせそうです. ただ今のところデフォルトで出力可能な形式は markdown だけのようで,出力形式を変えたい場合は自前で API -> String を実装する必要があります.

仕組みを知る上で前提となる知識

(※ご存知の方は読み飛ばしてください)

Type operators

https://downloads.haskell.org/~ghc/7.8.3/docs/html/users_guide/data-type-extensions.html

オペレータシンボルを型コンストラクタとして扱うための拡張です.-XTypeOperators で有効になります. 型やクラスの宣言に利用されるオペレータを除けば,プレフィックスに : がなくても良い?みたいです.

servant では :>:<|> がこれに相当します.

Type-Level Literals

https://downloads.haskell.org/~ghc/7.8.3/docs/html/users_guide/type-level-literals.html

数値や文字列を型レベルの定数として扱うことができるようです. -XDataKinds で有効になります (DataKinds 自体は,型を kind に,値コンストラクタを型コンストラクタに持ち上げる拡張).

以下は上記ページの例そのままですが,Label "x" が型として機能しています.

-- 引用元: https://downloads.haskell.org/~ghc/7.8.3/docs/html/users_guide/type-level-literals.html

data Label (l :: Symbol) = Get      -- String は Symbol kind

class Has a l b | a l -> b where
  from :: a -> Label l -> b

data Point = Point Int Int deriving Show

instance Has Point "x" Int where from (Point x _) _ = x
instance Has Point "y" Int where from (Point _ y) _ = y

example = from (Point 1 2) (Get :: Label "x")

もちろん Get :: Label "y" とすれば example2 を返します.

servant では API のルーティングパスを表現するところで利用されています.

Kind polymorphism

https://downloads.haskell.org/~ghc/7.8.3/docs/html/users_guide/kind-polymorphism.html

kind が多相的に扱えるようになります.何て呼ぶんでしょうか?多相カインド? -XPolyKinds で有効になります.

kind が明示されていない部分は * ではなく kind 変数として推論されるようになるみたいです.

data T m a = MkT (m a)

↓GHCi

*Test> :kind T
T :: (* -> *) -> * -> *

*Test> :set -XPolyKinds
*Test> :kind T
T :: (k -> *) -> k -> *     -- m の kind が * -> * から k -> * (変数あり) に変わった

Proxy

base の Data.Proxy に定義された,型情報を持つだけの data です.

data Proxy t = Proxy

↓GHCi

Prelude Data.Proxy> :kind Proxy
Proxy :: k -> *

kind が多相的になっているため,*NatSymbol といった kind に関係なく様々な型を持つことができます.

servant の :> はいろいろな kind を持った型をとるため,Proxy を使って定義されています.

-- 引用元: https://hackage.haskell.org/package/servant-0.2.1/docs/src/Servant-API-Sub.html#:>
data (path :: k) :> a = Proxy path :> a
infixr 9 :>

ちょっとだけ内部に潜る

Application の作成

serve に渡した p :: Proxy layoutserver :: Server layout から,HasServer クラスの route 関数 (の実装) が RoutingApplication を作成します.

RoutingApplication は WAI の Application が少し変更されたもので,servant の RoutingResult を扱うようになっているため toApplication 関数でこれを変換して最終的な Application にしています.

-- 引用元: https://hackage.haskell.org/package/wai-3.0.2/docs/src/Network-Wai.html#Application
type Application = Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived

-- 引用元: https://hackage.haskell.org/package/servant-server-0.2.2/docs/src/Servant-Server.html#serve
serve :: HasServer layout => Proxy layout -> Server layout -> Application
serve p server = toApplication (route p server)

ルーティング処理

実際にルーティングを行っている route 関数は Visitor パターンのような実装になっています.HasServer の instance 実装でパターンマッチしているようなイメージ?であっているでしょうか.

パスやパラメータ/戻り値の型は Proxy layout から決定され,Server layout もその型に従うように要求されます.

-- 引用元: https://hackage.haskell.org/package/servant-server-0.2.2/docs/src/Servant-Server-Internal.html#HasServer

class HasServer layout where
  type Server layout :: *
  route :: Proxy layout -> Server layout -> RoutingApplication


API を結合する :<|> の場合,以下のように分離された a :: Server ab :: Server b をそれぞれまた route に引き渡すような実装になっています. a が失敗した場合に b が実行されるようになっていることもわかります.

-- 引用元: https://hackage.haskell.org/package/servant-server-0.2.2/docs/src/Servant-Server-Internal.html#instance%20HasServer%20(a%20:<|>%20b)

instance (HasServer a, HasServer b) => HasServer (a :<|> b) where
  type Server (a :<|> b) = Server a :<|> Server b   -- ここは type operator
  route Proxy (a :<|> b) request respond =          -- ここは constructor
    route pa a request $ \ mResponse ->
      if isMismatch mResponse
        then route pb b request $ \mResponse' -> respond (mResponse <> mResponse')
        else respond mResponse

    where pa = Proxy :: Proxy a
          pb = Proxy :: Proxy b


途中の path をたどっていく部分は,WAI の Request から取得したリクエストパスと,シンボルとして型に埋め込まれた文字列 path とを比較し,一致すればさらに進み,一致しなければ失敗 (NotFound) としてレスポンスを返していることがわかります.

-- 引用元: https://hackage.haskell.org/package/servant-server-0.2.2/docs/src/Servant-Server-Internal.html#instance%20HasServer%20(path%20:>%20sublayout)

instance (KnownSymbol path, HasServer sublayout) => HasServer (path :> sublayout) where
  type Server (path :> sublayout) = Server sublayout
  route Proxy subserver request respond = case pathInfo request of
    (first : rest)
      | first == cs (symbolVal proxyPath)    -- ここで文字列を取り出して比較
      -> route (Proxy :: Proxy sublayout) subserver request{
           pathInfo = rest
         } respond
    _ -> respond $ failWith NotFound

    where proxyPath = Proxy :: Proxy path

なお,symbolVal は型レベルリテラル (Symbol) を文字列として取り出す関数です.

symbolVal (Proxy :: Proxy "hoge") == "hoge"     -- True


パラメータがある場合 (例えばサンプルコードの Capture "id" Int) は,capturedText から a 型の値に変換され (ここで FromTextインスタンスが必要になる),ハンドラの方 (route の引数である subserver :: Server layout) は a -> Server sublayout 型としてキャプチャしたパラメータの値に適用されています.

-- 引用元: https://hackage.haskell.org/package/servant-server-0.2.2/docs/src/Servant-Server-Internal.html#instance%20HasServer%20(Capture%20capture%20a%20:>%20sublayout)

instance (KnownSymbol capture, FromText a, HasServer sublayout)
      => HasServer (Capture capture a :> sublayout) where

  type Server (Capture capture a :> sublayout) =
     a -> Server sublayout

  route Proxy subserver request respond = case pathInfo request of
    (first : rest)
      -> case captured captureProxy first of
           Nothing  -> respond $ failWith NotFound
           Just v   -> route (Proxy :: Proxy sublayout) (subserver v) request{ -- *ここ
                         pathInfo = rest
                       } respond
    _ -> respond $ failWith NotFound

    where captureProxy = Proxy :: Proxy (Capture capture a)


最終的に Proxy layout の末尾に指定された HTTP Method と戻り値の型の部分までたどり着き,ここで WAI のレスポンスが構築されます.

-- 引用元: https://hackage.haskell.org/package/servant-server-0.2.2/docs/src/Servant-Server-Internal.html#instance%20HasServer%20(Get%20result)

instance ToJSON result => HasServer (Get result) where
  type Server (Get result) = EitherT (Int, String) IO result
  route Proxy action request respond
    | null (pathInfo request) && requestMethod request == methodGet = do
        e <- runEitherT action    -- ここでハンドラの実行結果を取り出す
        respond . succeedWith $ case e of
          Right output ->
            responseLBS ok200 [("Content-Type", "application/json")] (encode output)
          Left (status, message) ->
            responseLBS (mkStatus status (cs message)) [] (cs message)
    | null (pathInfo request) && requestMethod request /= methodGet =
        respond $ failWith WrongMethod
    | otherwise = respond $ failWith NotFound

EitherTLeft は失敗したときのステータスコードとメッセージの組,Right は成功したときの戻り値をそれぞれ表しています.

  type Server (Get result) = EitherT (Int, String) IO result

おわりに

あまり深追いはしていませんが,ざっと仕組みは追えたと思います.

ルーティングの部分を型で守りたいと思い apiary を試していましたが,servant も興味深い選択肢の一つだと思いました.

*1:加えて,読み進める過程で自身の知識不足がよく分かりましたので,いろいろ書き残そうと思いました.