packages feed

github-tools-0.1.2: web/TokTok/Webhooks.hs

{-# LANGUAGE OverloadedStrings #-}
module TokTok.Webhooks (newApp) where

import           Control.Concurrent.MVar    (MVar, modifyMVar_, newMVar,
                                             swapMVar)
import           Control.Concurrent.Suspend (sDelay)
import           Control.Concurrent.Timer   (TimerIO, oneShotRestart,
                                             oneShotTimer)
import qualified Data.Aeson.Encode.Pretty   as Aeson
import qualified Data.Algorithm.DiffContext as Diff
import qualified Data.ByteString            as BS
import qualified Data.ByteString.Lazy       as LBS
import qualified Data.CaseInsensitive       as CI
import           Data.Map                   (Map)
import qualified Data.Map                   as Map
import qualified Data.Maybe                 as Maybe
import           Data.Text                  (Text)
import qualified Data.Text                  as Text
import qualified Data.Text.Encoding         as Text (decodeUtf8With, encodeUtf8)
import qualified Data.Text.Encoding.Error   as Text (lenientDecode)
import qualified Data.Text.IO               as Text
import           Data.UUID                  (UUID)
import           GitHub.Types               (Payload (..))
import           GitHub.WebHook.Handler     (Error (..), Handler (..),
                                             removeNulls, runHandler)
import           Network.HTTP.Types         (HeaderName, status200, status501)
import           Network.Wai                (Application, Request, Response,
                                             getRequestBodyChunk,
                                             requestHeaders, responseLBS)
import           System.Environment         (lookupEnv)
import qualified Text.PrettyPrint           as PP
import           TokTok.Handlers            (handleEvent)


responseOK :: Response
responseOK = responseLBS status200 [("Content-Type", "text/plain")] "OK"

responseError :: LBS.ByteString -> Response
responseError = responseLBS status501 [("Content-Type", "text/plain")]


decodeUtf8 :: BS.ByteString -> Text
decodeUtf8 = Text.decodeUtf8With Text.lenientDecode


showDiff :: Text -> Text -> Text
showDiff a b = Text.pack . PP.render . toDoc $ diff
  where
    toDoc = Diff.prettyContextDiff (PP.text "payload")
                                   (PP.text "value")
                                   (PP.text . Text.unpack)
    diff = Diff.getContextDiff linesOfContext (Text.lines a) (Text.lines b)
    linesOfContext = 3


showError :: Error -> Text
showError InvalidRequest   = "InvalidRequest"
showError UnsignedRequest  = "UnsignedRequest"
showError InvalidSignature = "InvalidSignature"
showError (ParseError err) = "ParseError " <> (Text.pack . show $ err)
showError (IncompleteParse value payload) =
    let a    = json . removeNulls $ payload
        b    = json . removeNulls $ value
        diff = showDiff a b
    in  "IncompleteParse:"
            <> "\n=== Diff ===\n"
            <> diff
            {-
            <> "\n=== Payload ===\n"
            <> a
            <> "\n=== Value ===\n"
            <> b
            -}
    where json = decodeUtf8 . LBS.toStrict . Aeson.encodePretty


handleError
    :: BS.ByteString -> [(HeaderName, BS.ByteString)] -> Error -> IO Response
handleError event headers err = do
    let res = showError err
    Text.putStrLn $ "Failure in event " <> Text.pack (show event) <> ": " <> res
    Text.putStrLn $ "HTTP headers: " <> Text.pack (show headers)
    return . responseError . LBS.fromStrict . Text.encodeUtf8 $ res


handlePayload :: (Payload -> IO Response) -> Bool -> BS.ByteString -> UUID -> Payload -> IO Response
handlePayload f isSigned event uuid payload = do
    Text.putStrLn
        $  unsignedMsg
        <> "Success in event "
        <> Text.pack (show event)
        <> ": UUID="
        <> Text.pack (show uuid)
    f payload
    where unsignedMsg = if isSigned then "" else "[UNSIGNED!] "


handleRequest :: (Payload -> IO Response) -> BS.ByteString -> [String] -> Request -> IO Response
handleRequest f event secretKeys req = do
    body   <- fullRequestBody mempty
    parsed <- runHandler Handler
        { hSecretKeys = secretKeys
        , hBody       = return body
        , hHeader     = return . flip lookup (requestHeaders req) . CI.mk
        }
    case parsed of
        Left err -> handleError event (requestHeaders req) err
        Right (uuid, payload) ->
            handlePayload f (not $ null secretKeys) event uuid payload

  where
    fullRequestBody body = do
        chunk <- getRequestBodyChunk req
        if BS.null chunk then return body else fullRequestBody $ body <> chunk


app :: (Payload -> IO Response) -> Application
app f req respond = case lookup "X-GitHub-Event" . requestHeaders $ req of
    Nothing -> do
        -- Respond OK if we don't get an event.
        putStrLn "Ignoring request without event type"
        respond responseOK
    Just event -> do
        putStrLn $ "Handling GitHub event: " ++ show event
        -- Get the github secret keys.
        secretKeys <- Maybe.maybeToList <$> lookupEnv "GITHUB_SECRET"
        respond =<< handleRequest f event secretKeys req

newApp :: IO Application
newApp = do
    -- Initialise task queue
    tasks <- newMVar Map.empty
    -- Start the task queue processing timer.
    timer <- oneShotTimer (processTasks tasks) (sDelay 30)  -- after 30 seconds
    return $ app (addTasks timer tasks)
  where
    processTasks :: MVar (Map Text (IO ())) -> IO ()
    processTasks tasks = do
        todo <- swapMVar tasks Map.empty
        putStrLn $ "processing " <> show (Map.size todo) <> " background tasks"
        sequence_ $ Map.elems todo

    addTasks :: TimerIO -> MVar (Map Text (IO ())) -> Payload -> IO Response
    addTasks timer tasks payload =
        case handleEvent payload of
            Nothing -> return responseOK
            Just (key, task) -> do
                modifyMVar_ tasks $ return . Map.insert key task
                success <- oneShotRestart timer
                return $ if success
                    then responseOK
                    else responseError "failed to start background processing task"