Share
Embed
Transcript
27 slides
-
Haskell/Servantを通してWebミドルウェアを捉え直す 仕事で Go で Web API を書いていて、認証ミドルウェアの型設計に悩む 同じ問題を Servant (Haskell
のフレームワーク) はどう扱うか覗いてみると、オモ ロいことやってた Haskell 以外で Web 開発してる人に新しい視点を共有できるかも……? ※ スライド中のコードは本質を保ちつつ簡略化しており、実際の定義と異なる場合があります -
Property 'user' does not exist on type 'Request<ParamsDictionary, any, any,
ParsedQs, Record<string, any>>'.ts(2339) 「Request.user なんかないよ」 -
Express の Request 型に user フィールドを生やす // express.d.ts declare global
{ namespace Express { interface Request { user?: User | null; } } } -
絶対に non-empty とプログラマはわかっているが 型チェッカに納得してもらうために実行時に non-empty check するか〜 (req, res) =>
{ if (!req.user) { ERROR!!! } // ... } -
Request.user フィールドが常に存在するということにするか〜 declare global { namespace Express { interface Request
{ user: AuthedUser; } } } でも認証噛ませてないエンドポイントまで Request.user が生えてしまう... router.get( "/signup", (req, res) => { // エラーにならない。あれ? console.log(req.user.id); }, -
欲:「認証を噛ませたリクエストハンドラは、 認証成功時のみ呼ばれる」を型で主張したい リクエストハンドラが (AuthedUser, Req, RespWriter) => ... なら 呼び出せるのは
AuthedUser (認証成功の証拠) を構築できた時だけ 現実: ミドルウェアはハンドラの型を変えられない ミドルウェアがもたらすハンドラの契約の変化を型に反映できない -
ミドルウェアはハンドラの型を変えない 多くのフレームワークにおけるミドルウェアの型↓ type Handler = (req: Req, resp: RespWriter) =>
Promise<void> type Middleware = ( req: Req, resp: RespWriter, next: (Req, RespWriter) => Promise<void> ) => Promise<void> // ミドルウェアの例 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, ) -
Middleware ≒ Handler => Handler ( req: Req, resp: RespWriter,
next: (Req, RespWriter) => Promise<void>, ) => Promise<void> ↓引数順を入れ替えると ( next: (Req, RespWriter) => Promise<void>, req: Req, resp: RespWriter, ) => Promise<void> ↓カリー化 (一部だけ) (next: (Req, RespWriter) => Promise<void>) => (req: Req, resp: RespWriter) => Promise<void> -
ハンドラの型を保つモデルにも一理あり: 合成可能性 Express ではアプリケーションをミドルウェアの合成で構築する An Express application is essentially a
series of middleware function calls https://expressjs.com/en/5x/guide/using-middleware/ ミドルウェアが Handler -> Handler であるおかげで、 再利用可能な部品を目的に合わせて組み合わせることで大きいものを作れる -
「ハンドラの型を変えるミドルウェア」を考えてみる 認証を「User付きハンドラ」→「ふつうのハンドラ」でモデル化してみる authMiddleware: (handlerAfterAuth: (User, Req, Resp) => Promise<void>) =>
(Req, Resp) => Promise<void> 一方、型を保つミドルウェアもある corsMiddleware: (handler: (Req, Resp) => Promise<void>) => (Req, Resp) => Promise<void> -
「ハンドラの型を変えるミドルウェア」を考えてみる authMiddleware: ((User, Req, Resp) => …) => ((Req, Resp)
=> …) corsMiddleware: ((Req, Resp) => …) => ((Req, Resp) => …) これらを組み合わせようと思うと、順序によって合成方法が異なる! CORS の後に認証: corsMiddleware (authMiddleware (handler)) 認証の後に CORS: \h -> authMiddleware (corsMiddleware . h) もっと激しく型を変えるミドルウェアが出てきたら、合成はどう定義される? ミドルウェアが自由にハンドラの型を変えられるとすると、 「2つの部品を合成する統一的な方法」が非自明になってしまった -
認証のある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) -> -
まず API 仕様を型レベル DSL で記述 type API = BasicAuth User
:> "orders" :> Get '[JSON] [Order] GET /orders のレスポンスは Order のリスト、 application/json Basic 認証がかかっていて、ログイン成功時のユーザー情報の型が User type API 自体はリクエストハンドラの型ではない 型レベルの項で記述された DSL 構文木のようなもの -
まず 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 (要認証) -
構文木 type API からいろいろなものが導出できる API 仕様 type API が決まれば、以下は自動的に導けるはず。 実際、構文木
type API をトラバースして解釈するインタプリタがそれぞれある 導出したいもの インタプリタ サーバーのハンドラ型 etc. HasServer クライアント関数・型 HasClient OpenAPI ドキュメント HasSwagger インタプリタは type class で実現されている -
type API をサーバー視点で解釈 type API = BasicAuth User :> "orders"
:> Get '[JSON] [Order] この仕様からサーバーの実装のほとんどは機械的に定まる ビジネスロジック「出力すべき [Order] をどうやって集めるか」だけ、空欄にしとく HasServer で API 仕様から以下を導出できる: 1. ルーティング・穴あきのサーバー実装 2. 穴を埋める部分の型 開発者はこの穴の型に沿う server 関数を実装する -
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 -
引数に User 型が生える原理 type instance Server (BasicAuth user :> api)
= user -> Server api つまり BasicAuth AuthedUser :> api に対応するサーバーの型は、 api に対応するハンドラの型に引数 AuthedUser -> を足したもの 「認証を通ったハンドラは AuthedUser を得られる」が型に反映されている! -
type API は合成可能なコンビネータからなる (:>) :: forall k. k -> Type
-> Type BasicAuth AuthedUser :> は「API 仕様を受け取って API 仕様を返す」コンビネータ コンビネータは「API仕様 → API仕様」という "形" (kind) を保つ → 合成可能 (ハンドラの型を保っているわけではない) Servant は「API 仕様のコンビネータ」という部品の合成で API を記述する -
ちなみに: パスパラメータもコンビネータ Servant で /orders/:id はこう↓ type API = "orders"
:> Capture "id" Int :> Get '[JSON] Order Server API = Int -> IO (Either ServerError Order) ハンドラの型に、パスパラメータに対応する引数 Int -> が追加される! -
まとめ ミドルウェアはハンドラの型を保つ変換でモデル化されることが多い しかし、契約の変化を型に反映できないつらみがある ただ、合成可能性のある簡単なモデル化ではある Servant: Web API を「API 仕様を記述するコンビネータの合成」で記述する コンビネータは「API仕様
→ API仕様」なので合成可能 ハンドラの型は API 仕様から導出され、契約の変化を型に反映できる 「認証」も「パスパラメータ」も同じ枠組みで扱われる Servant は型レベルプログラミングを駆使した面白いフレームワークなので、 導出された型を見てみたり、内部の仕組みまで深ぼったりすると楽しいです!