Slide 1

Slide 1 text

Haskell/Servantを通してWebミドルウェアを捉え直す 関数型まつり2026 pizzacat83

Slide 2

Slide 2 text

Haskell/Servantを通してWebミドルウェアを捉え直す 仕事で Go で Web API を書いていて、認証ミドルウェアの型設計に悩む 同じ問題を Servant (Haskell のフレームワーク) はどう扱うか覗いてみると、オモ ロいことやってた Haskell 以外で Web 開発してる人に新しい視点を共有できるかも……? ※ スライド中のコードは本質を保ちつつ簡略化しており、実際の定義と異なる場合があります

Slide 3

Slide 3 text

認証つき API 実装するぞ〜〜

Slide 4

Slide 4 text

Property 'user' does not exist on type 'Request>'.ts(2339) 「Request.user なんかないよ」

Slide 5

Slide 5 text

Express の Request 型に user フィールドを生やす // express.d.ts declare global { namespace Express { interface Request { user?: User | null; } } }

Slide 6

Slide 6 text

認証ミドルウェアを通った後だから、 user は non-empty なのに

Slide 7

Slide 7 text

絶対に non-empty とプログラマはわかっているが 型チェッカに納得してもらうために実行時に non-empty check するか〜 (req, res) => { if (!req.user) { ERROR!!! } // ... }

Slide 8

Slide 8 text

Request.user フィールドが常に存在するということにするか〜 declare global { namespace Express { interface Request { user: AuthedUser; } } } でも認証噛ませてないエンドポイントまで Request.user が生えてしまう... router.get( "/signup", (req, res) => { // エラーにならない。あれ? console.log(req.user.id); },

Slide 9

Slide 9 text

欲:「認証を噛ませたリクエストハンドラは、 認証成功時のみ呼ばれる」を型で主張したい リクエストハンドラが (AuthedUser, Req, RespWriter) => ... なら 呼び出せるのは AuthedUser (認証成功の証拠) を構築できた時だけ 現実: ミドルウェアはハンドラの型を変えられない ミドルウェアがもたらすハンドラの契約の変化を型に反映できない

Slide 10

Slide 10 text

ミドルウェアはハンドラの型を変えない 多くのフレームワークにおけるミドルウェアの型↓ type Handler = (req: Req, resp: RespWriter) => Promise type Middleware = ( req: Req, resp: RespWriter, next: (Req, RespWriter) => Promise ) => Promise // ミドルウェアの例 const authMiddleware = async (req: Req, resp: Resp, next: Handler) => { const authResult = await auth(req); if (!authResult.ok) { resp.send(403); return; } await next( { ...req, user: authResult.user }, resp, )

Slide 11

Slide 11 text

Middleware ≒ Handler => Handler ( req: Req, resp: RespWriter, next: (Req, RespWriter) => Promise, ) => Promise ↓引数順を入れ替えると ( next: (Req, RespWriter) => Promise, req: Req, resp: RespWriter, ) => Promise ↓カリー化 (一部だけ) (next: (Req, RespWriter) => Promise) => (req: Req, resp: RespWriter) => Promise

Slide 12

Slide 12 text

ここまでのまとめ ミドルウェアは「リクエストハンドラの型を保つ作用」としてモデル化されがち ミドルウェアはハンドラの契約を変えうるのに、型を保つモデルでは、契約の変 化を型に反映できない

Slide 13

Slide 13 text

ハンドラの型を保つモデルにも一理あり: 合成可能性 Express ではアプリケーションをミドルウェアの合成で構築する An Express application is essentially a series of middleware function calls https://expressjs.com/en/5x/guide/using-middleware/ ミドルウェアが Handler -> Handler であるおかげで、 再利用可能な部品を目的に合わせて組み合わせることで大きいものを作れる

Slide 14

Slide 14 text

「ハンドラの型を変えるミドルウェア」を考えてみる 認証を「User付きハンドラ」→「ふつうのハンドラ」でモデル化してみる authMiddleware: (handlerAfterAuth: (User, Req, Resp) => Promise) => (Req, Resp) => Promise 一方、型を保つミドルウェアもある corsMiddleware: (handler: (Req, Resp) => Promise) => (Req, Resp) => Promise

Slide 15

Slide 15 text

「ハンドラの型を変えるミドルウェア」を考えてみる authMiddleware: ((User, Req, Resp) => …) => ((Req, Resp) => …) corsMiddleware: ((Req, Resp) => …) => ((Req, Resp) => …) これらを組み合わせようと思うと、順序によって合成方法が異なる! CORS の後に認証: corsMiddleware (authMiddleware (handler)) 認証の後に CORS: \h -> authMiddleware (corsMiddleware . h) もっと激しく型を変えるミドルウェアが出てきたら、合成はどう定義される? ミドルウェアが自由にハンドラの型を変えられるとすると、 「2つの部品を合成する統一的な方法」が非自明になってしまった

Slide 16

Slide 16 text

無秩序に「自由にハンドラの型を変えられるミドルウェアを部品とするフレーム ワーク」を考えると、合成方法が非自明で困った フレームワーク側が、部品たるものにいい感じの要件・制約を課すことで、その 中では合成がきちんと定まるような枠組みができると嬉しい 「ハンドラの型を保つ制約」は課さずに、でも合成ができる枠組みが欲しい! →Servant を見てみよう

Slide 17

Slide 17 text

Servant (Haskell) は「認証」をどうモデル化してるん だろう?

Slide 18

Slide 18 text

認証のあるAPIの実装例 GET /orders が「自分の注文一覧」を返す API main = run 8080 (serveWithContext api ctx server) -- (1) API の仕様 ( 型レベルDSL で記述) type API = BasicAuth User :> "orders" :> Get '[JSON] [Order] api :: Proxy API api = Proxy -- (2) サーバーの実装 server :: Server API -- サーバー実装の型は `API` から自動導出 server user = do orders <- listOrders (userId user) pure orders -- (3) 認証の中身の実装 ctx = checkBasicAuth :. ... where checkBasicAuth :: BasicAuthCheck User checkBasicAuth db = BasicAuthCheck $ (username, password) ->

Slide 19

Slide 19 text

まず API 仕様を型レベル DSL で記述 type API = BasicAuth User :> "orders" :> Get '[JSON] [Order] GET /orders のレスポンスは Order のリスト、 application/json Basic 認証がかかっていて、ログイン成功時のユーザー情報の型が User type API 自体はリクエストハンドラの型ではない 型レベルの項で記述された DSL 構文木のようなもの

Slide 20

Slide 20 text

まず API 仕様を型レベル DSL で記述 もちろん、複数エンドポイントのある API も記述できる type API = "books" :> Get '[JSON] [Book] :<|> BasicAuth User :> ( "orders" :> Get '[JSON] [Order] :<|> "settings" :> Get '[JSON] Settings ) :<|> で枝を作れる GET /books (認証不要) GET /orders (要認証) GET /settings (要認証)

Slide 21

Slide 21 text

構文木 type API からいろいろなものが導出できる API 仕様 type API が決まれば、以下は自動的に導けるはず。 実際、構文木 type API をトラバースして解釈するインタプリタがそれぞれある 導出したいもの インタプリタ サーバーのハンドラ型 etc. HasServer クライアント関数・型 HasClient OpenAPI ドキュメント HasSwagger インタプリタは type class で実現されている

Slide 22

Slide 22 text

type API をサーバー視点で解釈 type API = BasicAuth User :> "orders" :> Get '[JSON] [Order] この仕様からサーバーの実装のほとんどは機械的に定まる ビジネスロジック「出力すべき [Order] をどうやって集めるか」だけ、空欄にしとく HasServer で API 仕様から以下を導出できる: 1. ルーティング・穴あきのサーバー実装 2. 穴を埋める部分の型 開発者はこの穴の型に沿う server 関数を実装する

Slide 23

Slide 23 text

Server API – 穴の型の導出 server :: Server API server user = do orders <- listOrders (userId user) pure orders Server _ は API 仕様を受け取って穴の型を返す 計算すると: Server API = User -> IO (Either ServerError [Order]) ユーザーを引数にとり、 Order リスト or ServerError を返す IO

Slide 24

Slide 24 text

引数に User 型が生える原理 type instance Server (BasicAuth user :> api) = user -> Server api つまり BasicAuth AuthedUser :> api に対応するサーバーの型は、 api に対応するハンドラの型に引数 AuthedUser -> を足したもの 「認証を通ったハンドラは AuthedUser を得られる」が型に反映されている!

Slide 25

Slide 25 text

type API は合成可能なコンビネータからなる (:>) :: forall k. k -> Type -> Type BasicAuth AuthedUser :> は「API 仕様を受け取って API 仕様を返す」コンビネータ コンビネータは「API仕様 → API仕様」という "形" (kind) を保つ → 合成可能 (ハンドラの型を保っているわけではない) Servant は「API 仕様のコンビネータ」という部品の合成で API を記述する

Slide 26

Slide 26 text

ちなみに: パスパラメータもコンビネータ Servant で /orders/:id はこう↓ type API = "orders" :> Capture "id" Int :> Get '[JSON] Order Server API = Int -> IO (Either ServerError Order) ハンドラの型に、パスパラメータに対応する引数 Int -> が追加される!

Slide 27

Slide 27 text

まとめ ミドルウェアはハンドラの型を保つ変換でモデル化されることが多い しかし、契約の変化を型に反映できないつらみがある ただ、合成可能性のある簡単なモデル化ではある Servant: Web API を「API 仕様を記述するコンビネータの合成」で記述する コンビネータは「API仕様 → API仕様」なので合成可能 ハンドラの型は API 仕様から導出され、契約の変化を型に反映できる 「認証」も「パスパラメータ」も同じ枠組みで扱われる Servant は型レベルプログラミングを駆使した面白いフレームワークなので、 導出された型を見てみたり、内部の仕組みまで深ぼったりすると楽しいです!