この記事では、「Haskell製ウェブアプリケーションフレームワークを作る配信」で配信していた、Haskell製ウェブアプリケーションフレームワークを作るプロジェクトについて振り返ります。Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリーを目指していましたが、開発を途中で止めることにしました。その振り返り — とりわけ、そのゴールに基づいて実装するのが困難だと分かった機能などを中心にまとめます。
Link to
here動機
そもそも、Haskellには既にServantやYesod、Scottyといった人気のフレームワークがあるにもかかわらず、なぜ新しいフレームワークを作ろうと思ったのでしょうか。第1に、かつて私が「Haskellの歩き方」という記事の「Webアプリケーション」の節で述べた、次の問題を解決したかったから、という理由があります:
ただしServant, Yesod, 共通した困った特徴があります。 それぞれがHaskellの高度な機能を利用した独特なDSLを提供しているため、仕組みがわかりづらい、という点です。 Servantは、「型レベルプログラミング」と呼ばれる、GHCの言語拡張を使った仕組みを駆使して、型宣言だけでREST APIの仕様を記述できるようにしています。 YesodもGHCの言語拡張をたくさん使っているのに加え、特に変わった特徴として、TemplateHaskellやQuasiQuoteという仕組みを利用して、独自のDSLを提供しています。 それぞれ、見慣れたHaskellと多かれ少なかれ異なる構文で書かなければいけない部分があるのです。 つまり、これらのうちどちらかを使う以上、どちらかの魔法を覚えなければならないのです。
この「どちらかの魔法を覚えなければならない」という問題は、初心者がHaskellでウェブアプリケーションを作る上で大きな壁になりえます。入門書に書いてあるHaskellの機能だけでは、ServantやYesodなどのフレームワークで書くコードを理解できず、サンプルコードから雰囲気で書かなければならないのです。これが、新しいフレームワークを作ろうとした一番の動機です。
その他、このフレームワークを開発し始めるより更に前から開発・執筆している、「失敗しながら学ぶ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関数は名前のとおりHTTPのGETメソッドに対応するエンドポイントを定義します。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関数で定義したエンドポイントはやっぱりステータスコード200(OK)を返します。この挙動を変えるには、先程指定したレスポンスの型のうち、MIMEタイプを指定していた箇所をWithStatus型でラップしましょう。型引数で指定しているタプルの1つ目の要素は、このようにHTTPのレスポンスに関する仕様をHaskellの型で指定するパラメーターとなっています。
この例では、Status503という型を指定しているため、HTTPステータスコード503(Service 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型の値を解釈すると、decimalPieceやparamPieceなどの値は生成した関数の引数を一つずつ追加します。
Link to
hereContent-Typeを複数指定する
Ruby on Railsのrespond_toメソッドなどで実現できるように、一つのエンドポイントで一つの種類のレスポンスボディーを、複数のContent-Typeで返す、といった機能は昨今のWebアプリケーションフレームワークではごく一般的な機能でしょう。wai-sampleの場合、例えば次のようにして、Customerという型の値をJSONやapplication/x-www-form-urlencodedな文字列として返すエンドポイントを定義できます:
sampleRoutes =
[ -- ... 中略 ...
, get @(ContentTypes '[Json, FormUrlEncoded], Customer)
-- ... 中略 ...
]これまでの例ではgetの型引数においてMIMEタイプを表す箇所に一つの型のみ(PlainText型)を指定していましたが、ここでは代わりにContentTypesという型を使用しています。ContentTypes型コンストラクターに、MIMEタイプを表す型の型レベルリストを渡せば、レスポンスボディーを表す一つの型に対して、複数のMIMEタイプを指定できるようになります。
なお、JsonやFormUrlEncodedと一緒に指定したCustomer型は、当然ToJSON・FromJSONやToForm・FromFormといった型クラスのインスタンスである必要があります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関数に渡すと、WAIのApplication型の値が出来上がります。Application型はWAIにおけるサーバーアプリケーションを表す型で、ServantやYesodなど他の多くのHaskell製フレームワークでも、最終的にこのApplication型の値を作るよう設計されています。上記の例はApplication型の値をWarpというウェブサーバーで動かす場合のコードです。Application型の値をWarpのrunEnv関数に渡すことで、指定したポート番号でアプリケーションを起動できます。
ここで起動したサーバーアプリケーションが、実際にエンドポイントへのリクエストを受け取った際実行する関数は、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が持つSimpleResponder(p -> IO resObj)を呼び出します。そしてSimpleResponderが返したresObjを、クライアントが要求したMIMEタイプに応じたレスポンスボディーに変換し、クライアントに返す、という流れで動くようになっています。
Link to
hereTemplate 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型の値を定義する際に指定したdecimalPieceやparamPieceをdeclareClient関数が回収して、生成した関数の引数に追加します。実際に生成した関数が受け取った引数は、もちろんパスの一部として当てはめるのに用います。
生成した関数の戻り値は、サーバーからのレスポンスを表す型です。get関数の型引数として渡した(PlainText, T.Text)や(ContentTypes '[Json, FormUrlEncoded], Customer)などにおけるT.TextやCustomerがそれに当たります。クライアントの関数はサーバーからのレスポンスを、MIMEタイプを表す型などに従って、この型に変換してから返すよう実装されているのです。
Link to
hereドキュメントの生成
ServantではOpenAPIに則ったドキュメントを生成するパッケージがあるように、Haskellの構文で定義したREST APIの仕様から、APIのドキュメントを生成する機能があると便利でしょう。wai-sampleでも、Handler型のリストからAPIのドキュメントを生成する機能を実装しました — 残念ながら完成度が低く、とても実用に耐えるものではありませんが。
ともあれ、試しに使ってみましょう。これまで例として紹介したsampleRoutesの各HandlerにshowHandlerSpecという関数を適用すると、次のように各エンドポイントへのパスやリクエスト・レスポンスの情報を取得することが出来ます:
> 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何故開発を止めるのか
開発をやめる最も大きな理由は、冒頭でも触れたとおり、当初考えていたゴールを達成するのが難しいと判断したからです6。wai-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-Typeがtext/plain、レスポンスボディーを表す型がText型 - ステータスコードが
503 Service Unavailableで、Content-Typeがtext/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 resObjResponseSpec (resTyp, resObj)とResponseSpec (Response resTyp resObj)の2つのインスタンスの違い、分かるでしょうか?まるで間違い探しですよね…😥。タプル型もResponse型もgetなどに渡す型レベルリストでの役割はほぼ同じで、最初はタプルだけをとることにしていたのですが、やむを得ない理由があってResponseを別途設けることにしました8。こうした分かりづらい部分が出来てしまったのも、失敗の1つです。
Link to
hereパスのパーサー: 実は<$>がすでに危ない
パスのパーサーを値レベルの、Applicativeな内部DSLとして実装した結果、Servantと比べて型安全性を損なってしまうという問題があることも、作ってから気付きました。例えば、次のように<$>に渡す関数としてコンストラクターでない、普通の関数を渡した場合です:
path "integers/" *> (show <$> decimalPiece)decimalPieceはRoute Integerという型で、それにshow <$>を適用した結果はRoute Stringとなります。Route Stringは、パスの一部として文字列を受け取ることを表す型ですから、上記の式はintegers/<任意の文字列>というパスを表すことになります。ところが!実際にサーバーアプリケーションがパスをパースするのに使っているのはdecimalPieceなので、整数でなければなりません。このように<$>を使うだけで、Route Stringという型が表すパスのパーサーと、実際にパースできるパスの仕様が食い違ってしまうことがあります。Applicative(厳密に言えばFunctorの機能ですが)を使ったDSLである以上、こうしたことが防げないのです。
まあ、実は同じ問題が同じようにApplicativeベースの内部DSLを使った他のライブラリーにもあるでしょうから、敢えて気にしない、という手もあるのかも知れませんが。ちなみに、似たような問題を解決するためrelational-recordというパッケージではFunctorやApplicativeは使わず、product-isomorphicというパッケージにおいて、言わば「コンストラクターだけが適用できるFunctor・Applicative」とも言うべき専用の型クラスを作ることで解決していました。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
hereOkapi
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という型のApplicative10なDSLを使って仕様を表現します。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
hereIHP
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-sampleやOkapiのようにパスの定義を1箇所で済ませられるわけではない(CanRouteとHasPathの2つの型クラスのインスタンスを定義する必要がある)ようですが、パーサーコンビネーターを使って自由にパスを定義できるところは似ていますね。
Link to
here終わりに
wai-sampleは、HaskellでWeb APIを実装するためのフレームワークとして、ServantやYesodのような既存のフレームワークとは異なるアプローチを試みました。残念ながら目標の達成が技術的に困難であることが分かり、開発を止めることにしましたが、HaskellでWeb APIを実装するため新しいアプローチとして、何かしら参考になれば幸いです。
先頭のスラッシュにご注意ください。wai-sampleが
Route型の値を処理する際は、先頭のスラッシュは付けない前提としています。↩︎諸般の事情で、wai-sampleでは
http-api-dataパッケージをフォークして使っています。そのため、ToForm型クラスなどの仕様がHackageにあるものと異なっています。最終的にwai-sampleを公開する際、フォークしたhttp-api-dataを新しいパッケージとして同時に公開する予定でした。↩︎名前から察せられるとおり
Simpleじゃない普通のResponder型もありますが、ここでは割愛します。Responder型はクエリーパラメーターやリクエストヘッダーなど、パスに含めるパラメーター以外の情報を受け取るためのものです。SimpleResponder型のすぐ近くで定義されているので、興味があったらご覧ください。↩︎パーサーコンビネーター以外のアプローチ、例えば基数木を使ってより多くのエンドポイントを高速に処理できるようにするのも可能でしょう。↩︎
ghcコマンドの-ddump-splicesオプションを使って、declareClient関数が生成したコードを貼り付けました。みなさんの手元で試す場合はstack build --ghc-options=-ddump-splicesなどと実行するのが簡単でしょう。↩︎もう1つは、大変申し訳ないですが、私自身のHaskellに対する情熱が落ち込んでしまった、という理由もあります😞。↩︎
今回は詳細を省きましたが
Free Applicativeを使ったDSLの実装は、WaiSample.Typesモジュールをご覧ください。↩︎詳しい理由は面倒なので解説しません!これまでに出てきたコードだけで推測できるはずですし考えてみてください!↩︎
実装に対する最後の修正からも既に約1年が過ぎました。記録を作るのも遅い…😥↩︎
個人的には、なぜ
Alternativeにしなかったのかが気になります。Body.optionalやHeaders.optionalなどは文字通りAlternativeのoptionalで実現できそうに見えるからです。↩︎