From ebc549f0b8b2cd09a471a2b88eb1bb1d7a4158a1 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 9 Feb 2025 18:07:58 +0900 Subject: [PATCH 01/13] (WIP) Add article of wai-sample --- preprocessed-site/posts/2024/wai-sample.md | 232 +++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 preprocessed-site/posts/2024/wai-sample.md diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md new file mode 100644 index 0000000..00bee9c --- /dev/null +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -0,0 +1,232 @@ +--- +title: 単純なHaskellのみでServant並に高機能なライブラリーを作ろうとした振り返り +subHeading: +headingBackgroundImage: ../img/background.png +headingDivClass: post-heading +author: YAMAMOTO Yuji +postedBy: YAMAMOTO Yuji(@igrep) +date: November 27, 2024 +tags: +... +--- + +この記事では、「[Haskell製ウェブアプリケーションフレームワークを作る配信](https://www.youtube.com/playlist?list=PLRVf2pXOpAzJMFN810EWwGrH_qii7DKyn)」で配信していた、Haskell製ウェブアプリケーションフレームワークを作るプロジェクトについて振り返ります。Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリーを目指していましたが、開発を途中で止めることにしました。その振り返り --- とりわけ、そのゴールに基づいて実装するのが原理上不可能だと分かった機能などを中心にまとめます。 + +# 動機 + +そもそも、Haskellには既にServantやYesod、Scottyといった人気のフレームワークがあるにもかかわらず、なぜ新しいフレームワークを作ろうと思ったのでしょうか。第一に、かつて私が[「Haskellの歩き方」という記事の「Webアプリケーション」の節](https://wiki.haskell.jp/Hikers%20Guide%20to%20Haskell.html#web%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3)で述べた、次の問題を解決したかったから、という理由があります: + +> ただしServant, Yesod, 共通した困った特徴があります。 +> それぞれがHaskellの高度な機能を利用した独特なDSLを提供しているため、仕組みがわかりづらい、という点です。 +> Servantは、「型レベルプログラミング」と呼ばれる、GHCの言語拡張を使った仕組みを駆使して、型宣言だけでREST APIの仕様を記述できるようにしています。 +> YesodもGHCの言語拡張をたくさん使っているのに加え、特に変わった特徴として、TemplateHaskellやQuasiQuoteという仕組みを利用して、独自のDSLを提供しています。 +> それぞれ、見慣れたHaskellと多かれ少なかれ異なる構文で書かなければいけない部分があるのです。 +> つまり、これらのうちどちらかを使う以上、どちらかの魔法を覚えなければならないのです。 + +この「どちらかの魔法を覚えなければならない」という問題は、初心者がHaskellでウェブアプリケーションを作る上で大きな壁になりえます。入門書に書いてあるHaskellの機能だけでは、ServantやYesodなどのフレームワークで書くコードを理解できず、サンプルコードから雰囲気で書かなければならないのです。これが、新しいフレームワークを作ろうとした一番の動機です。 + +その他、このフレームワークを開発し始めるより更に前から開発・執筆している、[「失敗しながら学ぶHaskell入門」](https://github.com/haskell-jp/makeMistakesToLearnHaskell/)をウェブアプリケーションとして公開する際のフレームワークとしても使おうという考えもありました。「失敗しながら学ぶHaskell入門」はタイトルの通りHaskell入門者のためのコンテンツです。そのため、Haskellを学習したばかりの人でも簡単に修正できるフレームワークにしたかったのです。 + +# できたもの + +ソースコードはこちらにあります: + +[igrep/wai-sample: Prototype of a new web application framework based on WAI.](https://github.com/igrep/wai-sample) + +YouTubeで配信する前から行っていた(私の前職である)IIJの社内勉強会中の開発と、全128回のYouTubeでのライブコーディングを経て(一部配信終了後に手を入れたこともありましたが)、次のような構文でウェブアプリケーションを記述できるようにしました: + +```haskell +{-# 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](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs)をご覧ください。上記はその一部に説明用のコメントを加えています。 + +上記のサンプルコードにおける`sampleRoutes`が、Web APIの仕様を定めている部分です: + +```haskell +sampleRoutes :: [Handler] +``` + +`Handler`という型の値のリストで、それぞれの`Handler`には、Web APIのエンドポイントを表すのに必要な情報が全て含まれています。wai-sampleでは、この`Handler`のリストを解釈してWAIベースのサーバーアプリケーションを実行したり、Template Haskellを通じてクライアントコードを生成したり、はたまたサーバーアプリケーションのドキュメントを生成したりすることができるようになっています。 + +## (1) 最も単純な例 + +```haskell +get @(PlainText, T.Text) "aboutUs" (path "about/us") (\_ -> return "About IIJ") +``` + +先程のサンプルコードから抜粋した最も単純な例↑では、`get`関数を使ってエンドポイントを定義しています。`get`関数は名前のとおりHTTPのGETメソッドに対応するエンドポイントを定義します。`TypeApplications`言語拡張を使って指定している`(PlainText, T.Text)`という型が、このエンドポイントが返すレスポンスの型を表しています。ここでは、`get`に渡す最後の引数に当たる関数([`Responder`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L104)と呼びます。詳細は後ほど)がレスポンスボディーとして返す型をお馴染みの`Text`型として指定しつつ、サーバーやクライアントが処理する際はMIMEタイプを`text/plain`として扱うように指定しています。 + +`get`関数の(値の)第1引数では、エンドポイントの名前を指定しています。この名前は、後述するクライアントコードを生成する機能において、関数名の一部として使われます。 + +`get`関数の第2引数は、エンドポイントのパスの仕様を表す[`Route`型](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L56-L65)の値です。この例では、`path`関数を使って`"about/us"`という単純な文字列を指定しています。結果、このエンドポイントのパスは`/about/us`となります[^leading-slash])。 + +[^leading-slash]: 先頭のスラッシュにご注意ください。wai-sampleが`Route`型の値を処理する際は、先頭のスラッシュは付けない前提としています。 + +`get`関数の最後の引数が、このエンドポイントがHTTPリクエストを受け取った際に実行する関数、[`Responder`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L104)です。ここでは、単純にレスポンスボディーとして文字列を返すだけの関数を指定しています。 + +## (2) ステータスコードを指定した例 + +```haskell +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)を返すエンドポイントを定義しています。 + +## (3) パスの中に含まれる整数を処理する例 + +よくあるWebアプリケーションフレームワークでは、パスの一部に含まれる整数など文字列以外の型の値を取得するための仕組みが用意されています。 + +Haskellにおいて、文字列から特定の型の値を取り出す...といえばそう、パーサーコンビネーターですね。wai-sampleでは、サーバーが受け取ったパスをパーサーコンビネーターでパースするようになっています。従って下記の例では、`/customer/123/transaction/abc`というパスを受け取った場合、`123`と`"abc"`をタプルに詰め込んで`Responder`に渡すパスのパーサーを定義しています: + +```haskell +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`などの値は生成した関数の引数を一つずつ追加します。 + +## Content-Typeを複数指定する + +Ruby on Railsの`respond_to`メソッドなどで実現できるように、一つのエンドポイントで一つの種類のレスポンスボディーを、複数のContent-Typeで返す、といった機能は昨今のWebアプリケーションフレームワークではごく一般的な機能でしょう。wai-sampleの場合、例えば次のようにして、`Customer`という型の値をJSONや`application/x-www-form-urlencoded`な文字列として返すエンドポイントを定義できます: + +```haskell +sampleRoutes = + [ -- ... 中略 ... + , get @(ContentTypes '[Json, FormUrlEncoded], Customer) + -- ... 中略 ... + ] +``` + +これまでの例では`get`の型引数においてMIMEタイプを表す箇所に一つの型のみ(`PlainText`型)を指定していましたが、ここでは代わりに`ContentTypes`という型を使用しています。`ContentTypes`型コンストラクターに、MIMEタイプを表す型の型レベルリストを渡せば、レスポンスボディーを表す一つの型に対して、複数のMIMEタイプを指定できるようになります。 + +なお、`Json`や`FormUrlEncoded`と一緒に指定した`Customer`型は、当然[`ToJSON`](https://hackage.haskell.org/package/aeson/docs/Data-Aeson-Types.html#t:ToJSON)・[`FromJSON`](https://hackage.haskell.org/package/aeson/docs/Data-Aeson-Types.html#t:FromJSON)や[`ToForm`](https://hackage.haskell.org/package/http-api-data/docs/Web-FormUrlEncoded.html#t:ToForm)・[`FromForm`](https://hackage.haskell.org/package/http-api-data/docs/Web-FormUrlEncoded.html#t:FromForm)といった型クラスのインスタンスである必要があります[^http-api-data]。レスポンスボディーとして指定した型が、同時に指定したMIMEタイプに対応する形式に変換できることを、保証できるようになっているのです。 + +[^http-api-data]: 諸般の事情で、wai-sampleでは[`http-api-data`パッケージをフォーク](https://github.com/igrep/http-api-data/tree/151de32409960354de3a3f786f20bc4a496d2b65)して使っています。そのため、`ToForm`型クラスなどの仕様がHackageにあるものと異なっています。最終的にwai-sampleを公開する際、フォークしたhttp-api-dataを新しいパッケージとして同時に公開する予定でした。 + +## サーバーアプリケーションとしての使い方 + +ここまでで定義した`Handler`型の値、すなわちWeb APIのエンドポイントの仕様に基づいてサーバーアプリケーションを実行するには、次のように書きます: + +```haskell +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 +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Server/Sample.hs)にあるコードと同じ内容です。 + +`get`関数などで作った`Handler`型のリストを`handles`関数に渡すと、WAIの[`Application`](https://hackage.haskell.org/package/wai-3.2.4/docs/Network-Wai.html#t:Application)型の値が出来上がります。`Application`型はWAIにおけるサーバーアプリケーションを表す型で、ServantやYesodなど他の多くのHaskell製フレームワークでも、最終的にこの`Application`型の値を作るよう設計されています。上記の例は`Application`型の値をWarpというウェブサーバーで動かす場合のコードです。`Application`型の値をWarpの`runEnv`関数に渡すことで、指定したポート番号でアプリケーションを起動できます。 + +ここで起動したサーバーアプリケーションが、実際にエンドポイントへのリクエストを受け取った際実行する関数は、`get`関数などの最後の引数にあたる関数です。その関数は`SimpleResponder`という型シノニム[^simple]が設定されており、次のような定義となっています: + +[^simple]: 名前から察せられるとおり`Simple`じゃない普通の`Responder`型もありますが、ここでは割愛します。`Responder`型はクエリーパラメーターやリクエストヘッダーなど、パスに含めるパラメーター以外の情報を受け取るためのものです。`SimpleResponder`型のすぐ近くで定義されているので、興味があったらご覧ください。 + +```haskell +type SimpleResponder p resObj = p -> IO resObj +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L106)より + +型パラメーター`p`は、エンドポイントのパスに含まれるパラメーターを表す型です。 + +hoge + +## Template Haskellによる、クライアントの生成 + +https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client/Sample.hs + +```haskell +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} + +import WaiSample.Client +import WaiSample.Sample + + +$(declareClient "sample" sampleRoutes) +``` + +## ドキュメントの生成 + +詳しくは割愛 + +# 何故開発を止めるのか + +# 想定通りにできなかったもの + +## レスポンスの型 + +「できたもの」の節では割愛しましたが、 + +返しうるレスポンスに複数の種類があるとき + +間違い探しみたいな型 + +https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types/Response.hs#L51-L57 + +https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs#L175-L182 + +# 実装し切れなかったもの + +## よりよいドキュメント生成機能 + +## よりよいリクエストヘッダー・クエリーパラメーター + +# 類似のライブラリー・解決策 + + + +開発している途中で見つけた類似のライブラリー + +# 終わりに From 425abd418387722e41afff607dc95ac9a50b5100 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 16 Feb 2025 16:12:19 +0900 Subject: [PATCH 02/13] Explain each type variables of `SimpleResponder` --- preprocessed-site/posts/2024/wai-sample.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index 00bee9c..483090a 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -177,7 +177,11 @@ type SimpleResponder p resObj = p -> IO resObj ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Types.hs#L106)より -型パラメーター`p`は、エンドポイントのパスに含まれるパラメーターを表す型です。 +型パラメーター`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`型の値を解釈することで、 hoge From 0f075d25df3d971834bf59510e2ea46532a4ff81 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 23 Feb 2025 18:33:40 +0900 Subject: [PATCH 03/13] Complete the section of server apps --- preprocessed-site/posts/2024/wai-sample.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index 483090a..b3cff29 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -181,12 +181,14 @@ type SimpleResponder p resObj = p -> IO resObj そして`resObj`は、エンドポイントが返すレスポンスボディーの型です。これまでの例でいうと、`get`関数の型引数で指定した`(PlainText, T.Text)`における`T.Text`型、`(ContentTypes '[Json, FormUrlEncoded], Customer)`における`Customer`型が該当します。 -`runSampleApp`は各`Handler`型の値を解釈することで、 +`runSampleApp`は各`Handler`型の値を解釈し、サーバーアプリケーションとして実行します。エンドポイントのパスの仕様(`(path "about/us")`など)をパーサーコンビネーターとして解釈し[^parser]、パースが成功した`Handler`が持つ`SimpleResponder`(`p -> IO resObj`)を呼び出します。そして`SimpleResponder`が返した`resObj`を、クライアントが要求したMIMEタイプに応じたレスポンスボディーに変換し、クライアントに返す、という流れで動くようになっています。 -hoge +[^parser]: パーサーコンビネーター以外のアプローチ、例えば基数木を使ってより多くのエンドポイントを高速に処理できるようにするのも可能でしょう。 ## Template Haskellによる、クライアントの生成 +hoge + https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client/Sample.hs ```haskell From 0f241700e75a2d766d2d40b795a8a8d9a931c318 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 2 Mar 2025 19:18:34 +0900 Subject: [PATCH 04/13] Introduction of client code generation in wai-sample --- preprocessed-site/posts/2024/wai-sample.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index b3cff29..ba73a93 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -187,9 +187,7 @@ type SimpleResponder p resObj = p -> IO resObj ## Template Haskellによる、クライアントの生成 -hoge - -https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client/Sample.hs +サーバーアプリケーションの定義だけであれば、Haskell以外のものも含め、従来の多くのウェブアプリケーションフレームワークでも可能でしょう。しかしServantを始め、昨今におけるREST APIの開発を想定したWebアプリケーションフレームワークは、クライアントコードを生成する機能まで備えていることが多いです。wai-sampleはそうしたフレームワークを目指しているため、当然クライアントコードの生成もできるようになっています: ```haskell {-# LANGUAGE DataKinds #-} @@ -203,6 +201,10 @@ import WaiSample.Sample $(declareClient "sample" sampleRoutes) ``` +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client/Sample.hs)からほぼそのままコピペしたコードです。 + +hoge + ## ドキュメントの生成 詳しくは割愛 From a5c188d4f3e3e320a43640e4b7b2de47464b0e5e Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 9 Mar 2025 19:22:12 +0900 Subject: [PATCH 05/13] Begin describing how to generate client code --- preprocessed-site/posts/2024/wai-sample.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index ba73a93..5d372e5 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -203,6 +203,8 @@ $(declareClient "sample" sampleRoutes) ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client/Sample.hs)からほぼそのままコピペしたコードです。 +上記の通り、クライアントコードの生成は`TemplateHaskell`を使って行います。`declareClient`という関数に、生成する関数の名前の接頭辞(prefix)とこれまで定義した`Handler`型のリスト(`sampleRoutes`)を渡すと、次のような型の関数の定義を生成します: + hoge ## ドキュメントの生成 From e16f446ba3023bf78d9def1c7a62adf3e95a5aa5 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 16 Mar 2025 18:09:03 +0900 Subject: [PATCH 06/13] (WIP) Describe functions generated by `declareClient` --- preprocessed-site/posts/2024/wai-sample.md | 40 +++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index 5d372e5..16f8544 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -203,7 +203,45 @@ $(declareClient "sample" sampleRoutes) ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client/Sample.hs)からほぼそのままコピペしたコードです。 -上記の通り、クライアントコードの生成は`TemplateHaskell`を使って行います。`declareClient`という関数に、生成する関数の名前の接頭辞(prefix)とこれまで定義した`Handler`型のリスト(`sampleRoutes`)を渡すと、次のような型の関数の定義を生成します: +上記の通り、クライアントコードの生成は`TemplateHaskell`を使って行います。`declareClient`という関数に、生成する関数の名前の接頭辞(prefix)とこれまで定義した`Handler`型のリスト(`sampleRoutes`)を渡すと、次のような型の関数の定義を生成します[^ddump-splices]: + +```haskell +sampleAboutUs :: Backend -> IO Text +sampleMaintenance :: Backend -> IO Text +sampleCustomerTransaction :: Backend -> Integer -> Text -> IO Text +``` + +[^ddump-splices]: `ghc`コマンドの`-ddump-splices`オプションを使って、`declareClient`関数が生成したコードを貼り付けました。みなさんの手元で試す場合は`stack build --ghc-options=-ddump-splices`などと実行するのが簡単でしょう。 + +生成された関数は、`get`関数などの第1引数として渡した関数の名前に、`declareClient`の第1引数として渡した接頭辞が付いた名前で定義されます。 + +生成された関数の第1引数、`Backend`型は、クライアントがサーバーアプリケーションに実際にHTTPリクエストを送るための関数です。次のように定義されています: + +```haskell +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`パッケージを使って実装したバックエンドの例です: + +```haskell +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 +``` + +ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client.hs#L225-L230)からほぼそのままコピペしたコードです。 + +他の引数 + +レスポンス hoge From 93dd30a30a01ba886ae1069feaacab98fc4e5b45 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 23 Mar 2025 18:01:46 +0900 Subject: [PATCH 07/13] Perhaps finish describing the client code --- preprocessed-site/posts/2024/wai-sample.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index 16f8544..599fc60 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -239,9 +239,9 @@ httpClientBackend rootUrl manager method pathPieces rawReqHds = do ℹ️[こちら](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Client.hs#L225-L230)からほぼそのままコピペしたコードです。 -他の引数 +`Backend`型以外の引数は、パスパラメーターを始めとする、HTTPリクエストを組み立てるのに必要な情報です。`get`関数などで`Handler`型の値を定義する際に指定した`decimalPiece`や`paramPiece`を`declareClient`関数が回収して、生成した関数の引数に追加します。実際に生成した関数が受け取った引数は、もちろんパスの一部として当てはめるのに用います。 -レスポンス +生成した関数の戻り値は、サーバーからのレスポンスを表す型です。`get`関数の型引数として渡した`(PlainText, T.Text)`や`(ContentTypes '[Json, FormUrlEncoded], Customer)`などにおける`T.Text`や`Customer`がそれに当たります。クライアントの関数はサーバーからのレスポンスを、MIMEタイプを表す型などに従って、この型に変換してから返すよう実装されています。 hoge From 7475b7dcf37a9f6b5b87e8e35ec33dc7d72033f7 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 30 Mar 2025 15:03:15 +0900 Subject: [PATCH 08/13] Begin to explain the documentation feature --- preprocessed-site/posts/2024/wai-sample.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index 599fc60..b645944 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -241,12 +241,12 @@ httpClientBackend rootUrl manager method pathPieces rawReqHds = do `Backend`型以外の引数は、パスパラメーターを始めとする、HTTPリクエストを組み立てるのに必要な情報です。`get`関数などで`Handler`型の値を定義する際に指定した`decimalPiece`や`paramPiece`を`declareClient`関数が回収して、生成した関数の引数に追加します。実際に生成した関数が受け取った引数は、もちろんパスの一部として当てはめるのに用います。 -生成した関数の戻り値は、サーバーからのレスポンスを表す型です。`get`関数の型引数として渡した`(PlainText, T.Text)`や`(ContentTypes '[Json, FormUrlEncoded], Customer)`などにおける`T.Text`や`Customer`がそれに当たります。クライアントの関数はサーバーからのレスポンスを、MIMEタイプを表す型などに従って、この型に変換してから返すよう実装されています。 - -hoge +生成した関数の戻り値は、サーバーからのレスポンスを表す型です。`get`関数の型引数として渡した`(PlainText, T.Text)`や`(ContentTypes '[Json, FormUrlEncoded], Customer)`などにおける`T.Text`や`Customer`がそれに当たります。クライアントの関数はサーバーからのレスポンスを、MIMEタイプを表す型などに従って、この型に変換してから返すよう実装されているのです。 ## ドキュメントの生成 +[ServantではOpenAPIに則ったドキュメントを生成するパッケージがある](https://hackage.haskell.org/package/servant-openapi3)ように、Haskellの構文で定義したREST APIの仕様から、APIのドキュメントを生成する機能があると便利でしょう。wai-sampleでも、`Handler`型のリストからAPIのドキュメントを生成する機能を + 詳しくは割愛 # 何故開発を止めるのか From 8ee72a25c991254f144f54a9be8f70ac78226802 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 6 Apr 2025 19:44:39 +0900 Subject: [PATCH 09/13] Add example of document generation by wai-sample --- preprocessed-site/posts/2024/wai-sample.md | 110 ++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index b645944..d362e85 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -245,7 +245,115 @@ httpClientBackend rootUrl manager method pathPieces rawReqHds = do ## ドキュメントの生成 -[ServantではOpenAPIに則ったドキュメントを生成するパッケージがある](https://hackage.haskell.org/package/servant-openapi3)ように、Haskellの構文で定義したREST APIの仕様から、APIのドキュメントを生成する機能があると便利でしょう。wai-sampleでも、`Handler`型のリストからAPIのドキュメントを生成する機能を +[ServantではOpenAPIに則ったドキュメントを生成するパッケージがある](https://hackage.haskell.org/package/servant-openapi3)ように、Haskellの構文で定義したREST APIの仕様から、APIのドキュメントを生成する機能があると便利でしょう。wai-sampleでも、`Handler`型のリストからAPIのドキュメントを生成する機能を実装しました --- 完成度が低く、とても実用に耐えるものではありませんが。 + +ともあれ、試しに使ってみましょう。これまで例として紹介した[`sampleRoutes`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs#L147)の各`Handler`に[`showHandlerSpec`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample.hs#L47)という関数を適用すると、次のようにhoge + +```haskell +> 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) + +aboutUsFinance "GET" /about/us/finance + Request: + Query Params: (none) + Headers: (none) + Response: (PlainText,Text) + +aboutFinance "GET" /about/finance + Request: + Query Params: (none) + Headers: (none) + Response: (PlainText,Text) + +aboutFinanceImpossible "GET" //about/finance/impossible + Request: + Query Params: (none) + Headers: (none) + Response: (PlainText,Text) + +customerId "GET" /customer/:param + Request: + Query Params: (none) + Headers: (none) + Response: ((ContentTypes (': * Json (': * FormUrlEncoded ('[] *)))),Customer) + +customerIdJson "GET" /customer/:param.json + Request: + Query Params: (none) + Headers: (optional (X-API-VERSION: Integer | X-API-REVISION: Integer)) + Response: Sum (': * (Json,Customer) (': * ((WithStatus Status503 Json),SampleError) ('[] *))) + +customerIdTxt "GET" /customer/:param.txt + Request: + Query Params: (none) + Headers: (none) + Response: Sum (': * (PlainText,Text) (': * (Response (WithStatus Status503 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) + +customerHeadered "GET" /customerHeadered + Request: + Query Params: (none) + Headers: (none) + Response: (Json,(Headered (': * (Header "X-RateLimit-Limit" Int) (': * (Header "X-RateLimit-Reset" UTCTime) ('[] *))) Customer)) + +customerIdTxtHeadered "GET" /customer/:param.txt-or-json + Request: + Query Params: (none) + Headers: (none) + Response: Sum (': * ((ContentTypes (': * PlainText (': * Json ('[] *)))),(Headered (': * (Header "X-RateLimit-Limit" Int) (': * (Header "X-RateLimit-Reset" UTCTime) ('[] *))) Text)) (': * (Response (Wit +hStatus Status503 (ContentTypes (': * Json (': * PlainText ('[] *))))) (Headered (': * (Header "X-ErrorId" Text) ('[] *)) Text)) ('[] *))) + +echoApiVersion "POST" /echoApiVersion/ + Request: + Query Params: (none) + Headers: (X-API-VERSION: Integer | X-API-REVISION: Integer) + Response: (Json,ApiVersion) + +getExampleRequestHeaders "GET" /exampleRequestHeaders/ + Request: + Query Params: (none) + Headers: (X-API-VERSION: Integer | X-API-REVISION: Integer) & X-API-KEY: Text + Response: (Json,ExampleRequestHeaders) + +echoApiVersionQ "POST" /echoApiVersionQ/ + Request: + Query Params: (apiVersion: Integer | apiRevision: Integer) + Headers: (none) + Response: (Json,QueryParamsApiVersion) + +getExampleQueryParams "GET" /exampleQueryParams/ + Request: + Query Params: (apiVersion: Integer | apiRevision: Integer) & apiKey: Text + Headers: (none) + Response: (Json,ExampleQueryParams) +``` 詳しくは割愛 From ef24c564710cceb988fea7b6af753f46fdba2ab4 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 13 Apr 2025 18:23:35 +0900 Subject: [PATCH 10/13] Omit the verbose part of example document generated by wai-sample --- preprocessed-site/posts/2024/wai-sample.md | 77 ++-------------------- 1 file changed, 4 insertions(+), 73 deletions(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index d362e85..409e20c 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -247,10 +247,10 @@ httpClientBackend rootUrl manager method pathPieces rawReqHds = do [ServantではOpenAPIに則ったドキュメントを生成するパッケージがある](https://hackage.haskell.org/package/servant-openapi3)ように、Haskellの構文で定義したREST APIの仕様から、APIのドキュメントを生成する機能があると便利でしょう。wai-sampleでも、`Handler`型のリストからAPIのドキュメントを生成する機能を実装しました --- 完成度が低く、とても実用に耐えるものではありませんが。 -ともあれ、試しに使ってみましょう。これまで例として紹介した[`sampleRoutes`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs#L147)の各`Handler`に[`showHandlerSpec`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample.hs#L47)という関数を適用すると、次のようにhoge +ともあれ、試しに使ってみましょう。これまで例として紹介した[`sampleRoutes`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs#L147)の各`Handler`に[`showHandlerSpec`](https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample.hs#L47)という関数を適用すると、次のように各エンドポイントへのパスやリクエスト・レスポンスの情報を取得することが出来ます: ```haskell -> mapM_ (TIO.putStrLn . showHandlerSpec) sampleRoutes +> mapM_ (TIO.putStrLn . showHandlerSpec) sampleRoutes index "GET" / Request: Query Params: (none) @@ -269,41 +269,7 @@ aboutUs "GET" /about/us Headers: (none) Response: (PlainText,Text) -aboutUsFinance "GET" /about/us/finance - Request: - Query Params: (none) - Headers: (none) - Response: (PlainText,Text) - -aboutFinance "GET" /about/finance - Request: - Query Params: (none) - Headers: (none) - Response: (PlainText,Text) - -aboutFinanceImpossible "GET" //about/finance/impossible - Request: - Query Params: (none) - Headers: (none) - Response: (PlainText,Text) - -customerId "GET" /customer/:param - Request: - Query Params: (none) - Headers: (none) - Response: ((ContentTypes (': * Json (': * FormUrlEncoded ('[] *)))),Customer) - -customerIdJson "GET" /customer/:param.json - Request: - Query Params: (none) - Headers: (optional (X-API-VERSION: Integer | X-API-REVISION: Integer)) - Response: Sum (': * (Json,Customer) (': * ((WithStatus Status503 Json),SampleError) ('[] *))) - -customerIdTxt "GET" /customer/:param.txt - Request: - Query Params: (none) - Headers: (none) - Response: Sum (': * (PlainText,Text) (': * (Response (WithStatus Status503 PlainText) Text) ('[] *))) +-- ... 中略 ... customerTransaction "GET" /customer/:param/transaction/:param Request: @@ -317,42 +283,7 @@ createProduct "POST" /products Headers: (none) Response: (PlainText,Text) -customerHeadered "GET" /customerHeadered - Request: - Query Params: (none) - Headers: (none) - Response: (Json,(Headered (': * (Header "X-RateLimit-Limit" Int) (': * (Header "X-RateLimit-Reset" UTCTime) ('[] *))) Customer)) - -customerIdTxtHeadered "GET" /customer/:param.txt-or-json - Request: - Query Params: (none) - Headers: (none) - Response: Sum (': * ((ContentTypes (': * PlainText (': * Json ('[] *)))),(Headered (': * (Header "X-RateLimit-Limit" Int) (': * (Header "X-RateLimit-Reset" UTCTime) ('[] *))) Text)) (': * (Response (Wit -hStatus Status503 (ContentTypes (': * Json (': * PlainText ('[] *))))) (Headered (': * (Header "X-ErrorId" Text) ('[] *)) Text)) ('[] *))) - -echoApiVersion "POST" /echoApiVersion/ - Request: - Query Params: (none) - Headers: (X-API-VERSION: Integer | X-API-REVISION: Integer) - Response: (Json,ApiVersion) - -getExampleRequestHeaders "GET" /exampleRequestHeaders/ - Request: - Query Params: (none) - Headers: (X-API-VERSION: Integer | X-API-REVISION: Integer) & X-API-KEY: Text - Response: (Json,ExampleRequestHeaders) - -echoApiVersionQ "POST" /echoApiVersionQ/ - Request: - Query Params: (apiVersion: Integer | apiRevision: Integer) - Headers: (none) - Response: (Json,QueryParamsApiVersion) - -getExampleQueryParams "GET" /exampleQueryParams/ - Request: - Query Params: (apiVersion: Integer | apiRevision: Integer) & apiKey: Text - Headers: (none) - Response: (Json,ExampleQueryParams) +-- ... 以下略 ... ``` 詳しくは割愛 From c1bc0c5ea758126b4cce2a7227a52ac18d01311a Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 20 Apr 2025 18:28:02 +0900 Subject: [PATCH 11/13] Complete description of wai-sample's documentation feature --- preprocessed-site/posts/2024/wai-sample.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2024/wai-sample.md index 409e20c..e52763b 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2024/wai-sample.md @@ -286,7 +286,7 @@ createProduct "POST" /products -- ... 以下略 ... ``` -詳しくは割愛 +...が、あまりに完成度が低いので、詳しくは解説しません。実際に上記のコード実行すると、`Response`の型などがとても人間に読めるような出力になっていないことが分かります。今どきのWeb APIフレームワークであればOpenAPIに則ったドキュメントを生成する機能が欲しいでしょうが、それもありません。この方向で拡張すれば実装できるとは思いますが、次の節で述べるとおり開発を止めることにしたので、ここまでとしておきます。 # 何故開発を止めるのか From 127e490ae0c960036a2ca46455c3277b08cd0dd3 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Mon, 5 May 2025 22:23:12 +0900 Subject: [PATCH 12/13] Make progress in wai-sample.md --- preprocessed-site/posts/{2024 => 2025}/wai-sample.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) rename preprocessed-site/posts/{2024 => 2025}/wai-sample.md (96%) diff --git a/preprocessed-site/posts/2024/wai-sample.md b/preprocessed-site/posts/2025/wai-sample.md similarity index 96% rename from preprocessed-site/posts/2024/wai-sample.md rename to preprocessed-site/posts/2025/wai-sample.md index e52763b..b3b71b7 100644 --- a/preprocessed-site/posts/2024/wai-sample.md +++ b/preprocessed-site/posts/2025/wai-sample.md @@ -5,12 +5,12 @@ headingBackgroundImage: ../img/background.png headingDivClass: post-heading author: YAMAMOTO Yuji postedBy: YAMAMOTO Yuji(@igrep) -date: November 27, 2024 +date: July 27, 2025 tags: ... --- -この記事では、「[Haskell製ウェブアプリケーションフレームワークを作る配信](https://www.youtube.com/playlist?list=PLRVf2pXOpAzJMFN810EWwGrH_qii7DKyn)」で配信していた、Haskell製ウェブアプリケーションフレームワークを作るプロジェクトについて振り返ります。Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリーを目指していましたが、開発を途中で止めることにしました。その振り返り --- とりわけ、そのゴールに基づいて実装するのが原理上不可能だと分かった機能などを中心にまとめます。 +この記事では、「[Haskell製ウェブアプリケーションフレームワークを作る配信](https://www.youtube.com/playlist?list=PLRVf2pXOpAzJMFN810EWwGrH_qii7DKyn)」で配信していた、Haskell製ウェブアプリケーションフレームワークを作るプロジェクトについて振り返ります。Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリーを目指していましたが、開発を途中で止めることにしました。その振り返り --- とりわけ、そのゴールに基づいて実装するのが困難だと分かった機能などを中心にまとめます。 # 動機 @@ -29,7 +29,7 @@ tags: # できたもの -ソースコードはこちらにあります: +ソースコードはこちら👇️にあります。名前は仮に「wai-sample」としました。 [igrep/wai-sample: Prototype of a new web application framework based on WAI.](https://github.com/igrep/wai-sample) @@ -290,6 +290,10 @@ createProduct "POST" /products # 何故開発を止めるのか +開発をやめる最も大きな理由は、冒頭でも触れたとおり、当初考えていたゴールを達成するのが難しいと判断したからです[^motive]。wai-sampleのゴールは、「Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリー」にすることでした。hoge + +[^motive]: もう1つは、大変申し訳ないですが、私自身のHaskellに対する情熱が落ち込んでしまった、という理由もあります😞。 + # 想定通りにできなかったもの ## レスポンスの型 @@ -304,6 +308,8 @@ https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed0 https://github.com/igrep/wai-sample/blob/b4ddb75a28b927b76ac7c4c182bad6812769ed01/src/WaiSample/Sample.hs#L175-L182 +## パスのパーサー: 実は`<$>`がすでに危ない + # 実装し切れなかったもの ## よりよいドキュメント生成機能 From 4dfd3c5f859897aa70fb138912d3b08e09b3e9b8 Mon Sep 17 00:00:00 2001 From: YAMAMOTO Yuji Date: Sun, 11 May 2025 19:16:28 +0900 Subject: [PATCH 13/13] Complete introduction of why I stop development --- preprocessed-site/posts/2025/wai-sample.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/preprocessed-site/posts/2025/wai-sample.md b/preprocessed-site/posts/2025/wai-sample.md index b3b71b7..56962c3 100644 --- a/preprocessed-site/posts/2025/wai-sample.md +++ b/preprocessed-site/posts/2025/wai-sample.md @@ -290,13 +290,13 @@ createProduct "POST" /products # 何故開発を止めるのか -開発をやめる最も大きな理由は、冒頭でも触れたとおり、当初考えていたゴールを達成するのが難しいと判断したからです[^motive]。wai-sampleのゴールは、「Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリー」にすることでした。hoge +開発をやめる最も大きな理由は、冒頭でも触れたとおり、当初考えていたゴールを達成するのが難しいと判断したからです[^motive]。wai-sampleのゴールは、「Servantのような型安全なAPI定義を、(Servantのような)高度な型レベルプログラミングも、(Yesodのような)TemplateHaskellもなしに可能にするライブラリー」にすることでした。ところが、後述の通りいくつかの機能においてそれが無理ではないか(少なくとも難しい)ということが発覚したのです。 [^motive]: もう1つは、大変申し訳ないですが、私自身のHaskellに対する情熱が落ち込んでしまった、という理由もあります😞。 -# 想定通りにできなかったもの +## 想定通りにできなかったもの: レスポンスの型 -## レスポンスの型 +hoge 「できたもの」の節では割愛しましたが、