Haskell Servant で GitHub Webhook
久々の投稿. とある事情で GitHub Webhook 用のサーバーを Haskell で書いたのでそのメモ書きです.
やったこと
- rio のロガーを Servant で使う
- servant-github-webhook を使って Webhook 用の Servant サーバーの構築
実際に書いたコードはこの辺りを見ると良いかな. 色々と途中のうえ,そもそも GitHub Webhook がメインではないリポジトリなので見にくい気がするけど.
Servant で rio モナド
これはまぁおまけですね. rio はロギングとかが便利なので,先に紹介して以降で利用する.
ロガーの準備
まずはロギング用のモナドを定義する.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE TypeOperators #-}
import RIO
import Data.Extensible
type Env = Record
"logger" >: LogFunc
'[
]
instance HasLogFunc Env where
= lens (view #logger) (\x y -> x & #logger `set` y) logFuncL
いわゆる Has パターンというやつ. これで RIO Env
というのがロガーを扱えるモナドとなる.
Servant でカスタムモナド
Servent Server のメイン関数は下記のように定義できる.
import RIO
import Data.Extensible
import qualified Network.Wai.Handler.Warp as Warp
import Servant
main :: IO ()
= do
main <- logOptionsHandle stdout False
logOpts $ \logger -> do
withLogFunc logOpts let env = #logger @= logger
<: nil :: Env
"Listening on port 8080"
hPutBuilder stdout 8080 $ app env
Warp.run
app :: Env -> Application
= undefined app
さて,問題はこの app
関数だ. 普通は Server
型と serve
関数を用いて次のように書く.
type API = ...
api :: Proxy API
= Proxy
api
server :: Server API
= ...
server
app :: Application
= serve api server app
Server
型の代わりに,カスタムモナドを利用するには ServerT
型と hoistServer
関数を用いる. それぞれの型定義は次のようになっている.
type Server api = ServerT api Handler
serve :: HasServer api '[] => Proxy api -> Server api -> Application
hoistServer :: HasServer api '[] => Proxy api -> (forall x. m x -> n x) -> ServerT api m -> ServerT api n
さぁあとは型パズルだ!
server :: ServerT (RIO Env) API
= ...
server
app :: Env -> Application
= serve api $ hoistServer api (runRIO env) server app env
これで server
関数で呼び出す, API ごとの関数で logInfo
のような rio のロギング関数を呼ぶことができる.
Servant で GitHub Webhhok
さて色々準備ができたので,いよいよ GitHub Webhook の方に話を移す. Haskell Servant で GitHub Webhook を使うには servant-github-webhookというパッケージを使う. 意外と,このパッケージの使い方を書いた記事がなく手間取ったのでまとめる.
ping API を作る
ping API は GitHub Webhook の設定がうまくできてるかを確認する API だ.
ping API は次のように設定すれば良い.
import GitHub.Data.Webhooks.Events
import Servant
import Servant.GitHub.Webhook
type API = "hook" :> WebhookAPI
type WebhookAPI
= GitHubEvent '[ 'WebhookPingEvent ]
:> GitHubSignedReqBody '[JSON] PublicEvent
:> Post '[JSON] ()
server :: ServerT (RIO Env) API
= pingWebhook
server
pingWebhook ::
RepoWebhookEvent -> ((), PublicEvent) -> Plant ()
= do
pingWebhook _ (_, ev) $ "Hook Ping Event: " <> displayShow ev logInfo
GitHub Webhook を使うには Servant の Context 機能でシークレットキーを渡す必要がある.
import System.Environment (getEnv)
main :: IO ()
= do
main ...
let key = gitHubKey $ fromString <$> getEnv "GH_SECRET"
8080 $ app env key
Warp.run
app :: Env -> GitHubKey PublicEvent -> Application
=
app env key :. EmptyContext) $
serveWithContext api (key
hoistServerWithContext api context (runRIO env) server
context :: Proxy '[ GitHubKey PublicEvent ]
= Proxy context
実行するときは GH_SECRET
環境変数に設定した文字列を GitHub Webhook の設定の Secret に書き込む.
push API を加える
もう一個 API を生やしてみよう.
type WebhookAPI
= GitHubEvent '[ 'WebhookPingEvent ]
:> GitHubSignedReqBody '[JSON] PublicEvent
:> Post '[JSON] ()
:<|> GitHubEvent '[ 'WebhookPushEvent ]
:> GitHubSignedReqBody '[JSON] PushEvent
:> Post '[JSON] ()
server :: ServerT (RIO Env) API
= pingWebhook :<|> pushWebhook
server
pushWebhook :: RepoWebhookEvent -> ((), PushEvent) -> Plant ()
= do
pushWebhook _ (_, ev) $ "Hook Push Event: " <> displayShow ev logInfo
これでビルドすると次のようなエラーが出てくる.
Main.hs:38:3: error:
• No instance for (HasContextEntry '[] (GitHubKey' () PushEvent))
arising from a use of ‘serveWithContext’
• In the expression: serveWithContext api (key :. EmptyContext)
In the expression:
serveWithContext api (key :. EmptyContext)
$ hoistServerWithContext api context (runRIO env) server
In an equation for ‘app’:
app env key
= serveWithContext api (key :. EmptyContext)
$ hoistServerWithContext api context (runRIO env) server
|
38 | serveWithContext api (key :. EmptyContext) $ hoistServerWithContext api context (runRIO env) server
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ググった結果,こうするといいらしい.
{-# LANGUAGE MultiParamTypeClasses #-}
import Servant.GitHub.Webhook hiding (GitHubKey, gitHubKey)
import qualified Servant.GitHub.Webhook (GitHubKey, gitHubKey)
app :: Env -> GitHubKey -> Application
= ...
app env key
context :: Proxy '[ GitHubKey ]
= Proxy
context
-- HACK
newtype GitHubKey =
GitHubKey (forall result. Servant.GitHub.Webhook.GitHubKey result)
gitHubKey :: IO ByteString -> GitHubKey
= GitHubKey (Servant.GitHub.Webhook.gitHubKey k)
gitHubKey k
instance HasContextEntry '[GitHubKey] (Servant.GitHub.Webhook.GitHubKey result) where
GitHubKey x :. _) = x getContextEntry (
おしまい
ちなみに,手元で試すときには ngrok を使った. 便利.