単純なHaskellのみでServant並に高機能なライブラリーを作ろうとした振り返り

Posted by YAMAMOTO Yuji(@igrep) on July 27, 2025

この記事では、「Haskell製ウェブアプリケーションフレームワークを作る配信」で配信していた、Haskell製ウェブアプリケーションフレームワークを作るプロジェクトについて振り返ります。Servantのような型安全なAPI定義を、Servantのような)高度な型レベルプログラミングも、Yesodのような)TemplateHaskellもなしに可能にするライブラリーを目指していましたが、開発を途中で止めることにしました。その振り返り — とりわけ、そのゴールに基づいて実装するのが困難だと分かった機能などを中心にまとめます。

Link to
here
動機

そもそも、Haskellには既にServantYesodScottyといった人気のフレームワークがあるにもかかわらず、なぜ新しいフレームワークを作ろうと思ったのでしょうか。第1に、かつて私がHaskellの歩き方」という記事の「Webアプリケーション」の節で述べた、次の問題を解決したかったから、という理由があります:

ただしServant, Yesod, 共通した困った特徴があります。 それぞれがHaskellの高度な機能を利用した独特なDSLを提供しているため、仕組みがわかりづらい、という点です。 Servantは、「型レベルプログラミング」と呼ばれる、GHCの言語拡張を使った仕組みを駆使して、型宣言だけでREST APIの仕様を記述できるようにしています。 YesodGHCの言語拡張をたくさん使っているのに加え、特に変わった特徴として、TemplateHaskellQuasiQuoteという仕組みを利用して、独自のDSLを提供しています。 それぞれ、見慣れたHaskellと多かれ少なかれ異なる構文で書かなければいけない部分があるのです。 つまり、これらのうちどちらかを使う以上、どちらかの魔法を覚えなければならないのです。

この「どちらかの魔法を覚えなければならない」という問題は、初心者がHaskellでウェブアプリケーションを作る上で大きな壁になりえます。入門書に書いてあるHaskellの機能だけでは、ServantYesodなどのフレームワークで書くコードを理解できず、サンプルコードから雰囲気で書かなければならないのです。これが、新しいフレームワークを作ろうとした一番の動機です。

その他、このフレームワークを開発し始めるより更に前から開発・執筆している、「失敗しながら学ぶHaskell入門」をウェブアプリケーションとして公開する際のフレームワークとしても使おうという考えもありました。「失敗しながら学ぶHaskell入門」はタイトルの通りHaskell入門者のためのコンテンツです。そのため、Haskellを学習したばかりの人でも簡単に修正できるフレームワークにしたかったのです。

Link to
here
できたもの

ソースコードはこちら👇️にあります。名前は仮に「wai-sample」としました。

igrep/wai-sample: Prototype of a new web application framework based on WAI.

YouTubeで配信する前から行っていた(私の前職である)IIJの社内勉強会中の開発と、全128回のYouTubeでのライブコーディングを経て(一部配信終了後に手を入れたこともありましたが)、次のような構文でウェブアプリケーションを記述できるようにしました:

{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications  #-}

import WaiSample

sampleRoutes :: [Handler]
sampleRoutes =
  [ -- ... 中略 ...

    -- (1) 最も単純な例
  , get @(PlainText, T.Text) "aboutUs" (path "about/us") (\_ -> return "About IIJ")

    -- (2) ステータスコードを指定した例
  , get @(WithStatus Status503 PlainText, T.Text) "maintenance" (path "maintenance")
      (\_ -> return "Sorry, we are under maintenance")

  -- ... 中略 ...

    -- (3) パスをパースして含まれる整数を取得する例
  , get @(PlainText, T.Text)
      "customerTransaction"
      ( (,) <$> (path "customer/" *> decimalPiece)
            <*> (path "/transaction/" *> paramPiece)
        )
      (\(cId, transactionName) ->
        return $ "Customer " <> T.pack (show cId) <> " Transaction " <> transactionName
        )

  -- ... 中略 ...
  ]

※完全なサンプルコードはWaiSample/Sample.hsをご覧ください。上記はその一部に説明用のコメントを加えています。

上記のサンプルコードにおけるsampleRoutesが、Web APIの仕様を定めている部分です:

sampleRoutes :: [Handler]

Handlerという型のリストで、それぞれのHandlerには、Web APIのエンドポイントを表すのに必要な情報が全て含まれています。wai-sampleでは、このHandlerのリストを解釈してWAIベースのサーバーアプリケーションを実行したり、Template Haskellを通じてクライアントコードを生成したり、はたまたサーバーアプリケーションのドキュメントを生成したりすることができるようになっています。

Link to
here
(1) 最も単純な例

get @(PlainText, T.Text) "aboutUs" (path "about/us") (\_ -> return "About IIJ")

先程のサンプルコードから抜粋した最も単純な例↑では、get関数を使ってエンドポイントを定義しています。get関数は名前のとおりHTTPGETメソッドに対応するエンドポイントを定義します。TypeApplications言語拡張を使って指定している(PlainText, T.Text)という型が、このエンドポイントが返すレスポンスの型を表しています。ここでは、getに渡す最後の引数に当たる関数(Responderと呼びます。詳細は後ほど)がレスポンスボディーとして返す型をお馴染みのText型として指定しつつ、サーバーやクライアントが処理する際はMIMEタイプをtext/plainとして扱うように指定しています。

get関数の(値の)第1引数では、エンドポイントの名前を指定しています。この名前は、後述するクライアントコードを生成する機能において、関数名の一部として使われます。

get関数の第2引数は、エンドポイントのパスの仕様を表すRouteの値です。この例では、path関数を使って"about/us"という単純な文字列を指定しています。結果、このエンドポイントのパスは/about/usとなります1)。

get関数の最後の引数が、このエンドポイントがHTTPリクエストを受け取った際に実行する関数、Responderです。ここでは、単純にレスポンスボディーとして文字列を返すだけの関数を指定しています。

Link to
here
(2) ステータスコードを指定した例

get @(WithStatus Status503 PlainText, T.Text) "maintenance" (path "maintenance")
  (\_ -> return "Sorry, we are under maintenance")

デフォルトでは、get関数で定義したエンドポイントはやっぱりステータスコード200OK)を返します。この挙動を変えるには、先程指定したレスポンスの型のうち、MIMEタイプを指定していた箇所をWithStatus型でラップしましょう。型引数で指定しているタプルの1つ目の要素は、このようにHTTPのレスポンスに関する仕様をHaskellの型で指定するパラメーターとなっています。

この例では、Status503という型を指定しているため、HTTPステータスコード503Service Unavailable)を返すエンドポイントを定義しています。

Link to
here
(3) パスの中に含まれる整数を処理する例

よくあるWebアプリケーションフレームワークでは、パスの一部に含まれる整数など、文字列型以外の値を取得するための仕組みが用意されています。

Haskellにおいて、文字列から特定の型の値を取り出す…といえばそう、パーサーコンビネーターですね。wai-sampleでは、サーバーが受け取ったパスをパーサーコンビネーターでパースするようになっています。従って下記の例では、/customer/123/transaction/abcというパスを受け取った場合、123"abc"をタプルに詰め込んでResponderに渡すパスのパーサーを定義しています:

get @(PlainText, T.Text)
  "customerTransaction"
  ( (,) <$> (path "customer/" *> decimalPiece)
        <*> (path "/transaction/" *> paramPiece)
    )
  (\(cId, transactionName) ->
    return $ "Customer " <> T.pack (show cId) <> " Transaction " <> transactionName
    )

実際のところここまでの話はRoute型の値をサーバーアプリケーションが解釈した場合の挙動です。Route型はパスの仕様を定義するApplicativeな内部DSLとなっています。これによって、サーバーアプリケーションだけでなくクライアントのコード生成機能やドキュメントの生成など、様々な応用ができるようになっています。詳しくは後述しますが、例えばクライアントのコード生成機能がRoute型の値を解釈すると、decimalPieceparamPieceなどの値は生成した関数の引数を一つずつ追加します。

Link to
here
Content-Typeを複数指定する

Ruby on Railsrespond_toメソッドなどで実現できるように、一つのエンドポイントで一つの種類のレスポンスボディーを、複数のContent-Typeで返す、といった機能は昨今のWebアプリケーションフレームワークではごく一般的な機能でしょう。wai-sampleの場合、例えば次のようにして、Customerという型の値をJSONapplication/x-www-form-urlencodedな文字列として返すエンドポイントを定義できます:

sampleRoutes =
  [ -- ... 中略 ...
  , get @(ContentTypes '[Json, FormUrlEncoded], Customer)
  -- ... 中略 ...
  ]

これまでの例ではgetの型引数においてMIMEタイプを表す箇所に一つの型のみ(PlainText型)を指定していましたが、ここでは代わりにContentTypesという型を使用しています。ContentTypes型コンストラクターに、MIMEタイプを表す型の型レベルリストを渡せば、レスポンスボディーを表す一つの型に対して、複数のMIMEタイプを指定できるようになります。

なお、JsonFormUrlEncodedと一緒に指定したCustomer型は、当然ToJSONFromJSONToFormFromFormといった型クラスのインスタンスである必要があります2。レスポンスボディーとして指定した型が、同時に指定したMIMEタイプに対応する形式に変換できることを、保証できるようになっているのです。

Link to
here
サーバーアプリケーションとしての使い方

ここまでで定義したHandler型の値、すなわちWeb APIのエンドポイントの仕様に基づいてサーバーアプリケーションを実行するには、次のように書きます:

import Network.Wai              (Application)
import Network.Wai.Handler.Warp (runEnv)

import WaiSample.Sample         (sampleRoutes)
import WaiSample.Server         (handles)


sampleApp :: Application
sampleApp = handles sampleRoutes


runSampleApp :: IO ()
runSampleApp = runEnv 8020 sampleApp

ℹ️こちらにあるコードと同じ内容です。

get関数などで作ったHandler型のリストをhandles関数に渡すと、WAIApplication型の値が出来上がります。Application型はWAIにおけるサーバーアプリケーションを表す型で、ServantYesodなど他の多くのHaskell製フレームワークでも、最終的にこのApplication型の値を作るよう設計されています。上記の例はApplication型の値をWarpというウェブサーバーで動かす場合のコードです。Application型の値をWarprunEnv関数に渡すことで、指定したポート番号でアプリケーションを起動できます。

ここで起動したサーバーアプリケーションが、実際にエンドポイントへのリクエストを受け取った際実行する関数は、get関数などの最後の引数にあたる関数です。その関数はSimpleResponderという型シノニム3が設定されており、次のような定義となっています:

type SimpleResponder p resObj = p -> IO resObj

ℹ️こちらより

型パラメーターpは、エンドポイントのパスに含まれるパラメーターを表す型です。これまでの例でget関数に渡した(path "about/us")((,) <$> (path "customer/" *> decimalPiece) <*> (path "/transaction/" *> paramPiece))という式で作られる、Route型の値を解釈した結果の型pです。

そしてresObjは、エンドポイントが返すレスポンスボディーの型です。これまでの例でいうと、get関数の型引数で指定した(PlainText, T.Text)におけるT.Text型、(ContentTypes '[Json, FormUrlEncoded], Customer)におけるCustomer型が該当します。

runSampleAppは各Handler型の値を解釈し、サーバーアプリケーションとして実行します。エンドポイントのパスの仕様((path "about/us")など)をパーサーコンビネーターとして解釈し4、パースが成功したHandlerが持つSimpleResponderp -> IO resObj)を呼び出します。そしてSimpleResponderが返したresObjを、クライアントが要求したMIMEタイプに応じたレスポンスボディーに変換し、クライアントに返す、という流れで動くようになっています。

Link to
here
Template Haskellによる、クライアントの生成

サーバーアプリケーションの定義だけであれば、Haskell以外のものも含め、従来の多くのウェブアプリケーションフレームワークでも可能でしょう。しかしServantを始め、昨今におけるREST APIの開発を想定したWebアプリケーションフレームワークは、クライアントコードを生成する機能まで備えていることが多いです。wai-sampleはそうしたフレームワークを目指しているため、当然クライアントコードの生成もできるようになっています:

{-# LANGUAGE DataKinds        #-}
{-# LANGUAGE TemplateHaskell  #-}
{-# LANGUAGE TypeApplications #-}

import WaiSample.Client
import WaiSample.Sample


$(declareClient "sample" sampleRoutes)

ℹ️こちらからほぼそのままコピペしたコードです。

上記の通り、クライアントコードの生成はTemplateHaskellを使って行います。declareClientという関数に、生成する関数の名前の接頭辞(prefix)とこれまで定義したHandler型のリスト(sampleRoutes)を渡すと、次のような型の関数の定義を生成します5:

sampleAboutUs :: Backend -> IO Text
sampleMaintenance :: Backend -> IO Text
sampleCustomerTransaction :: Backend -> Integer -> Text -> IO Text

生成された関数は、get関数などの第1引数として渡した関数の名前に、declareClientの第1引数として渡した接頭辞が付いた名前で定義されます。

生成された関数の第1引数、Backend型は、クライアントがサーバーアプリケーションに実際にHTTPリクエストを送るための関数です。次のように定義されています:

import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Network.HTTP.Client        as HC

type Backend = Method -> Url -> RequestHeaders -> IO (HC.Response BL.ByteString)

このバックエンドを、例えばhttp-clientパッケージの関数を使って実装することで、生成された関数がサーバーアプリケーションにリクエストを送ることができます。以下は実際にhttp-clientパッケージを使って実装したバックエンドの例です:

import qualified Network.HTTP.Client        as HC
import qualified Data.ByteString.UTF8       as BS

httpClientBackend :: String -> Manager -> Backend
httpClientBackend rootUrl manager method pathPieces rawReqHds = do
  req0 <- parseUrlThrow . BS.toString $ method <> B.pack " " <> BS.fromString rootUrl <> pathPieces
  let req = req0 { HC.requestHeaders = rawReqHds }
  httpLbs (setRequestIgnoreStatus req) manager

ℹ️こちらからほぼそのままコピペしたコードです。

Backend型以外の引数は、パスパラメーターを始めとする、HTTPリクエストを組み立てるのに必要な情報です。get関数などでHandler型の値を定義する際に指定したdecimalPieceparamPiecedeclareClient関数が回収して、生成した関数の引数に追加します。実際に生成した関数が受け取った引数は、もちろんパスの一部として当てはめるのに用います。

生成した関数の戻り値は、サーバーからのレスポンスを表す型です。get関数の型引数として渡した(PlainText, T.Text)(ContentTypes '[Json, FormUrlEncoded], Customer)などにおけるT.TextCustomerがそれに当たります。クライアントの関数はサーバーからのレスポンスを、MIMEタイプを表す型などに従って、この型に変換してから返すよう実装されているのです。

Link to
here
ドキュメントの生成

ServantではOpenAPIに則ったドキュメントを生成するパッケージがあるように、Haskellの構文で定義したREST APIの仕様から、APIのドキュメントを生成する機能があると便利でしょう。wai-sampleでも、Handler型のリストからAPIのドキュメントを生成する機能を実装しました — 残念ながら完成度が低く、とても実用に耐えるものではありませんが。

ともあれ、試しに使ってみましょう。これまで例として紹介したsampleRoutesの各HandlershowHandlerSpecという関数を適用すると、次のように各エンドポイントへのパスやリクエスト・レスポンスの情報を取得することが出来ます:

> mapM_ (TIO.putStrLn . showHandlerSpec) sampleRoutes
index "GET" /
  Request:
    Query Params: (none)
    Headers: (none)
  Response: (PlainText,Text)

maintenance "GET" /maintenance
  Request:
    Query Params: (none)
    Headers: (none)
  Response: ((WithStatus Status503 PlainText),Text)

aboutUs "GET" /about/us
  Request:
    Query Params: (none)
    Headers: (none)
  Response: (PlainText,Text)

-- ... 中略 ...

customerTransaction "GET" /customer/:param/transaction/:param
  Request:
    Query Params: (none)
    Headers: (none)
  Response: (PlainText,Text)

createProduct "POST" /products
  Request:
    Query Params: (none)
    Headers: (none)
  Response: (PlainText,Text)

-- ... 以下略 ...

…が、前述の通りあまりに完成度が低いので、詳しくは解説しません。実際に上記のコード実行すると、Responseの型などがとても人間に読めるような出力になっていないことが分かります。今どきのWeb APIフレームワークであればOpenAPIに則ったドキュメントを生成する機能が欲しいでしょうが、それもありません。この方向で拡張すれば実装できるとは思いますが、次の節で述べるとおり開発を止めることにしたので、ここまでとしておきます。

Link to
here
何故開発を止めるのか

開発をやめる最も大きな理由は、冒頭でも触れたとおり、当初考えていたゴールを達成するのが難しいと判断したからです6wai-sampleのゴールは、「Servantのような型安全なAPI定義を、Servantのような)高度な型レベルプログラミングも、Yesodのような)TemplateHaskellもなしに可能にするライブラリー」にすることでした。ところが、後述の通りいくつかの機能においてそれが無理ではないか(少なくとも難しい)ということが発覚したのです。

Link to
here
想定通りにできなかったもの: レスポンスに複数のパターンがあるとき

「できたもの」の節では割愛しましたが、wai-sampleでは、サーバーが返すレスポンスに複数のケースがあるエンドポイント — 例えば、一方ではステータスコード200 OKと共に取得できたリソースの情報を返しつつ、一方では403 Forbiddenと共にエラーメッセージを返す — の実装もサポートしています。例えば次のように書けば、/customer/:id.txtというパスで複数の種類のレスポンスを返すエンドポイントを定義することが出来ます:

get @(Sum '[(PlainText, T.Text), Response (WithStatus Status503 PlainText) T.Text])
      "customerIdTxt"
      -- /customer/:id.txt
      (path "customer/" *> decimalPiece <* path ".txt")
      (\i ->
        if i == 503
          then return . sumLift $ Response @(WithStatus Status503 PlainText) ("error" :: T.Text)
          else return . sumLift $ "Customer " <> T.pack (show i))

ℹ️こちらからほぼそのままコピペしたコードです。

get関数の型引数に、随分仰々しい型が現れました。Sumという型は、名前のとおり和型を作ります。型レベルリストの要素としてContent-Typeやステータスコードを表す型と、実際のレスポンスボディーの型を組み合わせたタプル(あるいは後述するResponse型)を渡すことで、複数のケースを持つレスポンスの型を定義しています。上記の例におけるSum '[(PlainText, T.Text), Response (WithStatus Status503 PlainText) T.Text]は、次の2つのケースを持つレスポンスの型を表しています:

  • ステータスコードが(デフォルトの)200 OKで、Content-Typetext/plain、レスポンスボディーを表す型がText
  • ステータスコードが503 Service Unavailableで、Content-Typetext/plain、レスポンスボディーを表す型がText

以上のように書くことで実装できるようにはしたのですが、これによって当初の目的である「高度な型レベルプログラミングなしに実装する」という目標から外れてしまいました。型レベルリストは「高度な型レベルプログラミング」に該当すると言って差し支えないでしょう。

なぜこのようなAPIになったのかというと、Web APIに対する「入力」に当たる、パスのパース(や、今回は実装しませんでしたがリクエストボディーなどの処理も)などと、Web APIからの「出力」に当たるレスポンスの処理では、実行時に使える情報が大きく異なっていたからです。「入力」は値レベルでも(高度な型レベルプログラミングなしで)Free Applicativeを応用したDSLを使えば7、サーバーアプリケーション・クライアントコード・ドキュメント、いずれにも実行時に解釈できるフレームワークにできた一方、レスポンスボディーなど「出力」の型は値レベルのDSLを書いても、サーバーアプリケーションを実行しない限りそれに整合しているかどうかが分からない、という原理的な問題が判明したからです。

例えば、レスポンスボディーの仕様を次のような内部DSLで定義できるようにしたとします:

get [(plainText, text), ((withStatus status503 plainText), text)]
    -- ...

型レベルプログラミングバージョンでは型引数に渡していた情報を、ほぼそのまま値レベルに落とし込んだものです。しかしこのように書いたとしても、サーバーアプリケーションを起動して、実際にクライアントからリクエストを受け取り、それに対してgetに渡した関数(Responder)がレスポンスの元となる値を返すまで、レスポンスボディーの型が正しいかどうか、検証できないのです。「レスポンスの元となる値」の型はライブラリーのユーザー自身がResponderで返す値の型ですし、実行時以前にコンパイル時に保証できていて欲しいものです。これが、値レベルのDSLを採用した場合の限界です。

それから、型レベルリストを使ったこと以外においても、複雑で分かりづらい要因があります。先程から少し触れているとおり、Content-Typeやステータスコードとレスポンスボディーの型を組み合わせを表すのに、タプル以外にもResponseという型を用いています。Response型とタプル型はいずれもResponseSpec(下記に転載)という型クラスのインスタンスとなることで、「Content-Typeやステータスコード」を表す型(ResponseType)とResponderがレスポンスボディーとして返す型(ResponseObject)を宣言することが出来ます:

class ResponseSpec resSpec where
  type ResponseType resSpec
  type ResponseObject resSpec

instance ResponseSpec (resTyp, resObj) where
  type ResponseType (resTyp, resObj) = resTyp
  type ResponseObject (resTyp, resObj) = resObj

instance ResponseSpec (Response resTyp resObj) where
  type ResponseType (Response resTyp resObj) = resTyp
  type ResponseObject (Response resTyp resObj) = Response resTyp resObj

ResponseSpec (resTyp, resObj)ResponseSpec (Response resTyp resObj)2つのインスタンスの違い、分かるでしょうか?まるで間違い探しですよね…😥。タプル型もResponse型もgetなどに渡す型レベルリストでの役割はほぼ同じで、最初はタプルだけをとることにしていたのですが、やむを得ない理由があってResponseを別途設けることにしました8。こうした分かりづらい部分が出来てしまったのも、失敗の1つです。

Link to
here
パスのパーサー: 実は<$>がすでに危ない

パスのパーサーを値レベルの、Applicativeな内部DSLとして実装した結果、Servantと比べて型安全性を損なってしまうという問題があることも、作ってから気付きました。例えば、次のように<$>に渡す関数としてコンストラクターでない、普通の関数を渡した場合です:

path "integers/" *> (show <$> decimalPiece)

decimalPieceRoute Integerという型で、それにshow <$>を適用した結果はRoute Stringとなります。Route Stringは、パスの一部として文字列を受け取ることを表す型ですから、上記の式はintegers/<任意の文字列>というパスを表すことになります。ところが!実際にサーバーアプリケーションがパスをパースするのに使っているのはdecimalPieceなので、整数でなければなりません。このように<$>を使うだけで、Route Stringという型が表すパスのパーサーと、実際にパースできるパスの仕様が食い違ってしまうことがあります。Applicative(厳密に言えばFunctorの機能ですが)を使ったDSLである以上、こうしたことが防げないのです。

まあ、実は同じ問題が同じようにApplicativeベースの内部DSLを使った他のライブラリーにもあるでしょうから、敢えて気にしない、という手もあるのかも知れませんが。ちなみに、似たような問題を解決するためrelational-recordというパッケージではFunctorApplicativeは使わず、product-isomorphicというパッケージにおいて、言わば「コンストラクターだけが適用できるFunctorApplicative」とも言うべき専用の型クラスを作ることで解決していました。wai-sampleもこれを使えないかと企みましたが、どうもうまく適用できなかったため諦めました。

Link to
here
実装し切れなかったもの

ウェブアプリケーションフレームワークとして実装すべき機能のうち、実装し切れなかったものは当然たくさんあります。例えば以下のような機能でしょう:

  • HTTPリクエストに関わるもの:
    • リクエストヘッダーの処理
    • クエリーパラメーターの処理
    • (この辺りは、リクエストボディーの処理と似たような要領で実装できるはず)
  • HTTPレスポンスに関わるもの:
    • 動的なHTMLの配信
    • ファイルシステムにあるファイルの配信
    • REST APIに特化したフレームワークであればこれらは不要でしょうが、拡張として簡単に追加できるようにはしたいですね)
  • 両方に関わるもの:
    • Cookieの読み書き
  • ドキュメント生成に関わるもの:
    • OpenAPIに準拠したドキュメント生成
  • などなど!

Link to
here
類似のライブラリー・解決策

手が遅いもので、私が最初にwai-sampleのリポジトリーに対して行った最初のコミットから、既に約5年の歳月が過ぎました9。当時は私の前職、IIJにおける社内勉強会のネタとして始めたのが懐かしいです。私が知る限り、当時はwai-sampleのように「値レベルのプログラミングで」「Servantのように1つの定義からクライアントやドキュメントの生成も出来る」ことを目指したライブラリーはなかったように思います。しかし実際のところ、執筆時点で次のライブラリーが類似の機能を実装しているようです。これらのライブラリーがいつ開発を始めたのかは分かりませんが、やはり私がwai-sampleを作り始めた時点で同じような問題意識を持った人はいたのでしょう。

Link to
here
Okapi

OkapiにあるEndpointという機能は「An Endpoint is an executable specification representing a single Operation that can be taken against your API.」と謳っているとおり、APIの仕様を表現する内部DSLを提供します。しかもこれから紹介するとおり、wai-sampleより幾分洗練されているように見えます。

Okapiでは下記のEndpointという型 — wai-sampleでいうHandlerに相当するようです — に、「Script」と呼ばれる値レベルDSLを設定して使うようです:

data Endpoint p q h b r = Endpoint
  { method :: StdMethod
  , path :: Path.Script p
  , query :: Query.Script q
  , body :: Body.Script b
  , headers :: Headers.Script h
  , responder :: Responder.Script r
  }

詳細はもちろん公式ドキュメントにも書かれていますが、読んでわかる範囲でこちらでも解説しましょう。Endpoint型の各フィールドは、HTTPリクエスト・レスポンスに関わる各要素の仕様を表しています。methodフィールドを除くすべてのフィールドは、それぞれのフィールドのために作られたScriptという型のApplicative10DSLを使って仕様を表現します。Path.Script pはパスの仕様、Query.Script qはクエリーパラメーターの仕様、Body.Script bはリクエストボディーの仕様、Headers.Script hはリクエストヘッダーの仕様、Responder.Script rはレスポンスの仕様、といったところです。

Script型のうち、特筆すべきはResponder.Scriptでしょう。Responder.Scriptでは、レスポンスの種類毎にレスポンスボディーの型やステータスコード、レスポンスヘッダーの型を、case analysisを表す型として定義できるようになっています。そして、Handler型はEndpoint型が各種Scriptを使って設定した値を使って、実際にResponse型の値を組み立てます:

(⚠️以下のコードは、Okapiのドキュメントにあったサンプルコードを元に、私が推測してコメントを追加したものです。間違っていたらごめんなさい hask(_ _)eller

-- | Responseにヘッダーを設定する関数群
data SecretHeaders = SecretHeaders
  { firstSecret :: Int -> Response -> Response
  , secondSecret :: Int -> Response -> Response
  }

-- | Responseにヘッダーとボディーを設定する関数群
--   レスポンスの種類毎にフィールドラベルを1つ備えた、case analysisを表す型
data MyResponders = MyResponders
  { allGood :: (SecretHeaders %1 -> Response -> Response) -> Text -> Response
  , notGood :: (() %1 -> Response -> Response) -> Text -> Response
  }

-- | `Responder.Script`として定義する、レスポンスの仕様
myResponderScript = do
  -- allGood の場合はレスポンスボディーは`Text`型で、ステータスコードは200。
  -- レスポンスヘッダーとしては、`IntSecret`と`X-Another-Secret`という
  -- `Int`型の2つのヘッダーを追加する。
  allGood <- Responder.json @Text status200 do
    addSecret <- AddHeader.using @Int "IntSecret"
    addAnotherSecret <- AddHeader.using @Int "X-Another-Secret"
    pure SecretHeaders {..}

  -- notGood の場合はレスポンスボディーは`Text`型で、ステータスコードは501。
  -- レスポンスヘッダーはなし。
  notGood <- Responder.json @Text status501 $ pure ()
  pure MyResponders {..}

-- | Responder.Scriptで定義したcase analysisを表す型、`MyResponders`を使って、
--   レスポンスを組み立てる関数。
--   `someNumber`が100未満なら`allGood`を、そうでなければ`notGood`を使う。
--   この関数が利用していない引数は、`Endpoint`型の他のフィールドに対応するもの。
myHandler someNumber _ _ _ _ (MyResponders allGood notGood) = do
  if someNumber < 100
    then return $ allGood
      (\(SecretHeaders firstSecret secondSecret) response -> secondSecret 0 $ firstSecret 7 response)
      "All Good!"
    else return $ notGood
      (\() response -> response)
      "Not Good!"

wai-sampleがうまく実装できなかった、レスポンスに複数のパターンがある場合の処理を、case analysisを表す型で実装しているのが興味深いですね。前述した「原理的な問題」に対する解決策なのでしょう。

Link to
here
IHP

IHP (Integrated Haskell Platform)は、Haskellで書かれたフルスタックなWebアプリケーションフレームワークです。wai-sampleのような、与えられたパスに基づいて対応する関数を呼び出す機能(ルーティング機能)はもちろんのこと、PostgreSQLと接続するORMやメールの送信、バックグラウンド処理に加えてGUIから管理する機能など、様々な機能を備えています。Architectureを読むと察せられるとおり、古き良きRuby on Railsのようなスタイルのフレームワークのようです。

そんなIHPのルーティング機能、とりわけREST APIの慣習では表現しきれず、カスタマイズしたパスを定義する際の機能は、まさにパスのパーサーコンビネーターを書くことで実装できるようになっています。以下はドキュメントにあった例をそのまま貼り付けています:

-- /posts/an-example-blog-post というような記事の名前(slug)や
-- /posts/f85dc0bc-fc11-4341-a4e3-e047074a7982 というような記事のIDから
-- 記事を表示するアクションを呼び出すルーティング

-- パスにあるパラメーターを表す型
data PostsController
    = ShowPostAction { postId :: !(Maybe (Id Post)), slug :: !(Maybe Text) }


-- CanRoute 型クラスのインスタンスで、
-- パスのパーサーコンビネーターを定義する
instance CanRoute PostsController where
    parseRoute' = do
        string "/posts/"
        let postById = do id <- parseId; endOfInput; pure ShowPostAction { postId = Just id, slug = Nothing }
        let postBySlug = do slug <- remainingText; pure ShowPostAction { postId = Nothing, slug = Just slug }
        postById <|> postBySlug


-- HasPath 型クラスのインスタンスで、
-- パスに含めるパラメーターからパスを生成する関数を定義する
instance HasPath PostsController where
    pathTo ShowPostAction { postId = Just id, slug = Nothing } = "/posts/" <> tshow id
    pathTo ShowPostAction { postId = Nothing, slug = Just slug } = "/posts/" <> slug


action ShowPostAction { postId, slug } = do
    post <- case slug of
            Just slug -> query @Post |> filterWhere (#slug, slug) |> fetchOne
            Nothing   -> fetchOne postId
    -- ...

wai-sampleOkapiのようにパスの定義を1箇所で済ませられるわけではない(CanRouteHasPath2つの型クラスのインスタンスを定義する必要がある)ようですが、パーサーコンビネーターを使って自由にパスを定義できるところは似ていますね。

Link to
here
終わりに

wai-sampleは、HaskellWeb APIを実装するためのフレームワークとして、ServantYesodのような既存のフレームワークとは異なるアプローチを試みました。残念ながら目標の達成が技術的に困難であることが分かり、開発を止めることにしましたが、HaskellWeb APIを実装するため新しいアプローチとして、何かしら参考になれば幸いです。


  1. 先頭のスラッシュにご注意ください。wai-sampleRoute型の値を処理する際は、先頭のスラッシュは付けない前提としています。↩︎

  2. 諸般の事情で、wai-sampleではhttp-api-dataパッケージをフォークして使っています。そのため、ToForm型クラスなどの仕様がHackageにあるものと異なっています。最終的にwai-sampleを公開する際、フォークしたhttp-api-dataを新しいパッケージとして同時に公開する予定でした。↩︎

  3. 名前から察せられるとおりSimpleじゃない普通のResponder型もありますが、ここでは割愛します。Responder型はクエリーパラメーターやリクエストヘッダーなど、パスに含めるパラメーター以外の情報を受け取るためのものです。SimpleResponder型のすぐ近くで定義されているので、興味があったらご覧ください。↩︎

  4. パーサーコンビネーター以外のアプローチ、例えば基数木を使ってより多くのエンドポイントを高速に処理できるようにするのも可能でしょう。↩︎

  5. ghcコマンドの-ddump-splicesオプションを使って、declareClient関数が生成したコードを貼り付けました。みなさんの手元で試す場合はstack build --ghc-options=-ddump-splicesなどと実行するのが簡単でしょう。↩︎

  6. もう1つは、大変申し訳ないですが、私自身のHaskellに対する情熱が落ち込んでしまった、という理由もあります😞。↩︎

  7. 今回は詳細を省きましたがFree Applicativeを使ったDSLの実装は、WaiSample.Typesモジュールをご覧ください。↩︎

  8. 詳しい理由は面倒なので解説しません!これまでに出てきたコードだけで推測できるはずですし考えてみてください!↩︎

  9. 実装に対する最後の修正からも既に約1年が過ぎました。記録を作るのも遅い…😥↩︎

  10. 個人的には、なぜAlternativeにしなかったのかが気になります。Body.optionalHeaders.optionalなどは文字通りAlternativeoptionalで実現できそうに見えるからです。↩︎