最近見かけたHaskell library: named
namedは関数においてnamed parameter(a.k.a. keyword arguments)を実現するためのライブラリ。OverloadedLabelsを使い、Symbolとargumentを紐づけることでnamed parameterを型として記述したり、引数渡しをしたりする。
最近見かけたHaskell library: hatter
hatterはHaskellでiOSまたはAndroidネイティブアプリを開発するためのライブラリ。アプリをビルドするにはnixが必要らしい。
ちなみに作者はジムでのウェイトリフティングの記録をつけるアプリを自作するためにこれを作ったらしい。
最近見かけたHaskell library: oauth2-server
oauth2-serverはOAuth 2.1 Authorization Server (AS)の実装。 Servantを使っているので、ServantによるWebアプリに組み込んで使える。
OAuth 2.1対応なので、authorization code grantのクライアントは必ずPKCEを使うよう制約される。Dynamic client registrationとAS metadata discoveryにも対応している。
最近見かけたHaskell library: valor
valorはdata validationツール。複雑なデータ構造のフィールドを様々な条件でチェックして、正常か異常かを検証する。 異常の場合はエラーメッセージをまとめて結果として返す。
パーサではなく、パース済みのデータの検証用ツールという位置づけ。 型の制約で表現しきれないデータ制約なんかを検証するといいだろう。
validatorはValor型で表現される。
data Valor i (m :: Type -> Type) e
iが入力データ型、mは任意のMonad、eがエラー型。(Valor i m)はMonadとなり、発生したエラーに対してダイナミックな処理を書ける。
Valorを実行するにはvalidateM関数などを使う。
validateM :: Monad m => Valor i m e -> i -> m (Either (Valid i) e)
Valid型はIdentity型と同等で、validation済みであることを型レベルで示す。
興味深いのは、validationが成功するとLeftが返り、失敗してエラーが発生するとRightが返ってくるという点。 エラー処理を主眼に置いているのでこういうAPIになっているのか。
引き続きHaskellでプログラムを書いて学んだこと
2年前に書いたプログラムの拡張開発をやった。
このプログラムはWebアプリで、OpenID Connectクライアントおよび(それとは独立した)OAuth 2.0 Authorization Server(AS)を実装する。 ASとしての実装は必要最低限で、Client Credentials GrantとDevice Authorization Grant (RFC 8628)を実装している。 Authorization Code GrantがないのでASとしてはちょっと特殊かもしれない。
今回は上記Webアプリを拡張し、OAuth 2.0 Resource Serverの役割も持たせて、とある機能をWeb APIとして実装した。 クライアントは上記ASで発行したアクセストークンを提示することで、このWeb APIを叩ける。
Servantを使ったコーディングスタイル
Web APIの定義とサーバ実装には引き続きservantを使った。
Web API endpointが増えるとservant関連のいろいろなデータ型や関数などが増えてだいぶコードが複雑になってしまった。
いろいろ考えたが、以下のようなコーディングスタイルで書くことにした。
- Endpointひとつひとつにtype (alias)をつけて命名する。
- Endpoint typeの記述はなるべくフラットにする (nestingはしない)
- Endpoint typeの直後に同名のhandler関数を記述する。
- Handler関数の型はendpoint type(にServerTを適用したもの)をつける。
このコーディングスタイルの例を以下に示す。
type GetAllUsers = "users" :> Get '[JSON] [User] getAllUsers :: MonadIO m => Env -> ServerT GetAllUsers m getAllUsers env = ... type GetUser = "users" :> Capture "userid" Integer :> Get '[JSON] User getUser :: MonadIO m => Env -> ServerT GetUser m getUser env userId = ... type PostUser = "users" :> ReqBody '[JSON] User :> Post '[JSON] User postUser :: MonadIO m => Env -> ServerT PostUser m postUser env postedUser = ... type Endpoints = GetAllUsers :<|> GetUser :<|> PostUser endpoints :: MonadIO m => Env -> ServerT Endpoints m endpoints env = getAllUsers env :<|> getUser env :<|> postUser env
このように、endpoint typeとhandler関数の名前をそろえることで、命名の負担を大幅に下げることができる。
また、handler関数の型の記述でServerTを積極的に使うことで、endpoint typeとの整合性を保つ。 handler関数の定義をendpoint typeの定義の直後に置けば、 handler関数の具体的な型はendpoint typeからわりと簡単に類推できる。
servant関連のドキュメントを見るとendpoint typeとhandler関数をそれぞれまとめて定義する、というスタイルもみられる。 例えば以下のような書き方である。
type Endpoints = "users" ( Get '[JSON] [User] :<|> Capture "userid" Integer :> Get '[JSON] User :<|> ReqBody '[JSON] User :> Post '[JSON] User ) endpoints :: MonadIO m => Env -> ServerT Endpoints m endpoints = ...
小規模なAPIならこれでいいが、endpointの数が増えると手に負えなくなる気がする。 特に下手にnested APIを使い始めるとhandler関数の書き方も複雑になる。
Web API endpointの仕様を記述して、その直後にhandler関数を記述する、というスタイルはFastAPIなんかでも採用されているものであり、やはりこのほうが分かりやすいと思う。
modern-uriのnormalizationについて
URIを扱うためにmodern-uri packageを使っている。
どうやらmodern-uriはいい感じのnormalizationをデフォルトでやるというポリシーになっているようだ。なので以下のようなround-trip propertyが必ずしもTrueにならない。
propRoundTrip :: Text -> Bool propRoundTrip validUri = (fmap URI.render $ URI.mkURI validUri) == pure validUri
https://github.com/mrkkrp/modern-uri/issues/26 でも議論されているが、modern-uriはnormalizationの一環として"empty path segmentを許容しない"というポリシーを定めている。 これにより、例えば以下のようなnormalizationが実施される。
## pathにおける連続スラッシュの除去 http://example.com/foo/bar//quux///piyo --> http://example.com/foo/bar/quux/piyo ## "/"のみのpathの除去 http://example.com/ --> http://example.com
唯一の例外はnon-empty pathにおけるtrailing slashであり、これはmodern-uriでも明示的にパースされる。
URI.mkURI "http://example.com/foo" /= URI.mkURI "http://example.com/foo/"
これはなぜかというと、relativeTo関数の挙動に影響するから。 relativeTo関数はURI relative referenceをbase URIに基づいてabsolute URIに変換する関数である。 このアルゴリズムはおそらく、Webページでrelative pathをhrefに指定したaタグを踏んだ時のリンク先を作るアルゴリズムなのだと思う。
>>> fmap (render . fromJust) $ relativeTo <$> mkURI "/buzz" <*> mkURI "http://example.com/foo/bar" "http://example.com/buzz" >>> fmap (render . fromJust) $ relativeTo <$> mkURI "buzz" <*> mkURI "http://example.com/foo/bar" "http://example.com/foo/buzz" >>> fmap (render . fromJust) $ relativeTo <$> mkURI "buzz" <*> mkURI "http://example.com/foo/bar/" "http://example.com/foo/bar/buzz"
やりたいことは分かるが、だいぶ複雑だな。。
ちなみにuri-bytestring packageではnormalizationについてかなり柔軟にオプションを設定できる。もっとも、normalizationはparse optionではなくserialize optionのようだ。
Network.Wai.Test
Web APIの単体テストにはNetwork.Wai.Testを使った。
このモジュールを使ったテストはWai Applicationを直接実行する。 そのため、HTTPサーバを立ち上げたり、HTTPのプロトコル処理をしたりする必要がない分、テストを速く実行できる。
Network.Wai.Testモジュールはわりとシンプルな機能しか提供していないが、 いくつかサポート関数を自力で書けば比較的快適にテストを書けた。
Literal type
Web APIのプロトコル処理を書いていると、データ構造の特定のフィールドの値に基づいて全体の構造が決まるというパターンに出くわすことがある。
例えばOAuth 2.0のToken endpointのリクエストオブジェクトは、grant_typeフィールドの値に基づいてその他のフィールドの仕様も決まる。
こういった場合、TypeScriptやPythonではLiteral typeを使った"discriminated union"というテクニックがよく知られている。
Haskellで上記のようなLiteral typeはあるだろうかと調べたところ、以下の2つを見つけた。
いずれも文字列データのLiteral typeに相当する。
これらの型コンストラクタはSymbol kindの型引数を1つとり、それと同等の値のみを持つデータ型を作る。
(なお、「値が一つしかないデータ型」をsingleton typeと呼ぶらしい)
ただし、SSymbolは非常にprimitiveなデータ型なため、プロトコル処理は自力で追加する必要がある。
一方、SymTagはaesonのFromJSON, ToJSON instanceがついているため、JSONの処理にはそのまま使える。
これらを使うと、上記のtoken requestデータ型は例えば以下のように書くことができる。
data ReqAuthorizationCode = ReqAuthorizationCode = { grantType :: SymTag "authorization_code" , code :: Text , redirectUri :: Maybe Text , clientId :: Maybe Text } data ReqClientCredentials = ReqClientCredentials { grantType :: SymTag "client_credentials" , scope :: Maybe Text }
ただし、token requestはapplication/x-www-form-urlencoded形式でエンコードされるため、ToForm, FromForm instanceが必要になる。
残念ながらこれらを持つLiteral type実装は見つからなかったので、今回はSymTagの実装を参考に自力でLiteral typeを作った。
OpenAPI対応
Web APIのドキュメントを作るにあたり、せっかくならOpenAPI specを出せるようにしたほうがいいだろうと思い、 servant-openapi3を使って記述した。
楽勝で自動生成できるだろうとタカをくくっていたが、読みやすいドキュメントを生成するにはリクエストやレスポンスのメッセージに個別のデータ型を割り当て、それらのToSchema instanceを丁寧に書いていく必要があった。
結局、この作業のために前述のLiteral typeを使ったデータ構造の見直しや、新たなnewtype wrapperの導入などの作業が必要になった。
また、Servant.API.Experimental.Auth.AuthProtectコンビネータに対応するHasOpenApi instance定義がなかったので、以下のようにして独自に追加した。
import qualified Data.HashMap.Strict.InsOrd as InsOrd import qualified Data.OpenApi as O import qualified Data.OpenApi.Internal.Schema as O import qualified Data.Text as T -- (中略) instance (KnownSymbol tag, HasOpenApi orig) => HasOpenApi (AuthProtect tag :> orig) where toOpenApi _ = O.allOperations . O.security %~ ((:) $ newSecurityRequirement secName) $ toOpenApi $ Proxy @orig where secName = T.pack $ symbolVal $ Proxy @tag newSecurityRequirement n = O.SecurityRequirement $ InsOrd.singleton n []
上記のコードは当該operationのsecurityフィールドにtagで示される認証メカニズム名称を追加するだけである。
認証メカニズムそのものの説明は別途OpenApiオブジェクトのcomponents.securitySchemesに追加しておく必要がある。
なお、似たような話は既にissueとしてあがっているようだ。
- HasOpenApi for BasicAuth · Issue #14 · biocad/servant-openapi3
- HasOpenApi instances for servant-auth · Issue #42 · biocad/servant-openapi3
servant-openapi3およびopenapi3 packageを使うことで、OpenAPI SpecのJSONファイルを生成できる。 ここからHTML形式のWeb APIドキュメントを生成するにはredocly CLIを使う。
$ npx @redocly/cli build-docs openapi-spec.yaml
docker build時のビルドキャッシュ
(2025-11-11追記)
ある程度以上の規模のHaskellプログラムをビルドするときは、たいてい依存パッケージのビルドに長時間を要することが頭痛のタネになってくる。
自分は普段stackは使わず、cabalを直接使ってビルドをしている。
その場合でもcabal freezeコマンドでcabal.project.freezeファイルを生成・固定しておけば、
手元の開発環境では依存パッケージのビルドキャッシュが効いてくれるため、ビルド時間はさほど気にならなくなる。
問題はHaskellプログラムをdockerコンテナ化するために、Dockerfile内でcabal buildする場合である。
何も考えずにDockerfileを書くと、docker build中の環境はビルドするたびにリセットされるため、cabal build向けのビルドキャッシュが効かない。
結果として、ちょっとコードを更新してdocker buildするたびに全依存パッケージのダウンロードとビルドが実行されてしまう。
こういった場合、DockerfileのRUNコマンドの--mount=type=cacheオプションを使うとよいようだ。
このオプションを指定すると、dockerのビルドエンジンはRUNの実行時に指定の(コンテナ内)ディレクトリをキャッシュしてくれる。
ビルドエンジンは、RUN実行後の指定ディレクトリの内容をてきとうな場所に保存しておき、次回実行時にはRUN実行前にそれを展開・マウントする。
このオプションを使うと、以下のように書くだけでcabal buildのビルドキャッシュが効くようになる。
RUN --mount=type=cache,target=/root/.cabal/store cabal build all
最近見かけたHaskell library: ecstatic
ecstaticはlibraryというよりもGHC拡張機能であり、executableのstatic linkを実現する。
従来の -static フラグではlibcもstatic linkされるが、それは非推奨らしい。ecstaticを使うことで、libcはdynamic linkにしつつ他のライブラリをstatic linkできる、らしい。