packages feed

wai-lambda-0.1.1.0: src/Network/Wai/Handler/Lambda.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE ViewPatterns #-}
{-# OPTIONS_GHC -fno-warn-deprecations #-}

module Network.Wai.Handler.Lambda where

import Control.Concurrent (forkIO)
import Control.DeepSeq (NFData)
import Control.Monad
import Data.Aeson ((.:))
import Data.Bifunctor
import Data.Function (fix)
import Data.List (partition)
import Data.Maybe (fromMaybe)
import GHC.Generics (Generic)
import Network.Wai (Application)
import System.Directory (renameFile)
import System.IO.Unsafe
import UnliftIO
import Text.Read (readMaybe)
import qualified Data.Binary.Builder as Binary
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Internal as Aeson
import qualified Data.Aeson.Parser as Aeson
import qualified Data.Aeson.Parser.Internal as Aeson
import qualified Data.Aeson.Types as Aeson
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as HMap
import qualified Data.IP as IP
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Vault.Lazy as Vault
import qualified Network.HTTP.Types as H
import qualified Network.Socket as Socket
import qualified Network.Wai as Wai
import qualified Network.Wai.Internal as Wai
import qualified System.IO.Temp as Temp

-- | The representation of the response sent back to API Gateway.
type RawResponse = (H.Status, H.ResponseHeaders, BS.ByteString)

-- | The settings for running an 'Application'.
--
-- See also:
--  * 'runSettings'
--
-- For simplicity use the following setters with 'defaultSettings':
--  * 'setTimeoutSeconds'
--  * 'setHandleTimeout'
--  * 'setHandleException'
--
data Settings = Settings
  { timeoutValue :: Int
    -- ^ How many microseconds before we declare a timeout
  , handleTimeout :: BS.ByteString -> IO RawResponse
    -- ^ How to handle a timeout
  , handleException :: BS.ByteString -> SomeException -> IO RawResponse
    -- ^ How to handle an exception thrown by the 'Application'
  }

-- | Run an 'Application'.
--
-- Continuously reads requests from @stdin@. Each line should be a a JSON
-- document as described in 'decodeInput'.
--
-- All requests will be timed out after 2 seconds. If any exception
-- is thrown while processing the request this will return an @HTTP 500
-- Internal Server Error@.
--
-- If you need more control use 'handleRequest' directly.
run :: Application -> IO ()
run = runSettings defaultSettings

runSettings :: Settings -> Application -> IO ()
runSettings settings app = xif BS.empty $ \loop leftover ->
    -- XXX: we don't use getLine because it errors out on EOF; here we deal
    -- with this explicitly
    BS.hGetSome stdin 4096 >>= \bs ->
      if BS.null bs
      then pure () -- EOF was reached
      else case second BS8.uncons $ BS8.break (== '\n') (leftover <> bs) of
        (_tmpLine, Nothing) -> loop (leftover <> bs)
        (line, Just ('\n', rest)) -> do
          void $ forkIO $ handleRequest settings app line
          loop rest
        -- This happens if 'break' found a newline character but 'uncons'
        -- returned something different
        (_tmpLine, Just{}) -> throwIO $ userError $
          "wai-lambda: The impossible happened: was expecting newline"

setTimeoutSeconds :: Int -> Settings -> Settings
setTimeoutSeconds tout settings = settings
    { timeoutValue = tout * 1000 * 1000 }

setHandleException
  :: (BS.ByteString -> SomeException -> IO RawResponse)
  -> Settings
  -> Settings
setHandleException handler settings = settings
    { handleException = handler}

setHandleTimeout
  :: (BS.ByteString -> IO RawResponse)
  -> Settings
  -> Settings
setHandleTimeout handler settings = settings
    { handleTimeout = handler}

defaultSettings :: Settings
defaultSettings = Settings
    { timeoutValue = defaultTimeoutValue
    , handleTimeout = defaultHandleTimeout
    , handleException = defaultHandleException
    }

defaultHandleException :: BS.ByteString -> SomeException -> IO RawResponse
defaultHandleException bs e = do
    putStrLn $
      "Could not process request: " <> show bs <>
      " error: " <> show e
    pure (H.status500, [], "Internal Server Error")

-- | Default request timeout. 2 seconds.
defaultTimeoutValue :: Int
defaultTimeoutValue = 2 * 1000 * 1000

defaultHandleTimeout :: BS.ByteString -> IO RawResponse
defaultHandleTimeout bs = do
    putStrLn $ "Timeout processing request: " <> show bs
    pure (H.status504, [], "Timeout")

-------------------------------------------------------------------------------
-- Request handling
-------------------------------------------------------------------------------

-- | Parse and handle the request.
--
-- * Returns 504 if no response is available after the specified timeout.
-- * Returns 500 if an exception occurs while processing the request.
-- * Throws an exception if the input cannot be parsed.
handleRequest
  :: Settings
  -> Application
  -> BS.ByteString -- ^ The request (see 'decodeInput')
  -> IO ()
handleRequest settings app bs = case decodeInput bs of
    Left err -> do
      -- The request couldn't be parsed. There isn't much we can do since we
      -- don't even know where to put the response.
      let msg = unlines
            [ "Cannot decode request " <> show err
            , "Request was: " <> show bs
            ]
      putStrLn msg
      throwIO $ userError msg
    Right (fp, mkReq) -> do
      req <- mkReq
      mresp <- timeout (timeoutValue settings) $ tryAny $ processRequest app req
      resp <- case mresp of
        Just (Right r) -> do
          (st, hdrs, body) <- readResponse r
          pure $ toJSONResponse st hdrs body
        Just (Left e) ->
          uncurry3 toJSONResponse <$> handleException settings bs e
        Nothing ->
          uncurry3 toJSONResponse <$> handleTimeout settings bs

      writeFileAtomic fp $ BL.toStrict $ Aeson.encode $ Aeson.Object resp

-- | Run the 'Request' through the 'Application'.
--
-- This function is completely dependent on the 'Application. Any exception
-- thrown by the 'Application' will be rethrown here. No timeout is
-- implemented: if the 'Application' never provides a 'Response' then
-- 'processRequest' won't return.
processRequest :: Application -> Wai.Request -> IO Wai.Response
processRequest app req = do
    mvar <- newEmptyMVar
    Wai.ResponseReceived <- app req $ \resp -> do
      putMVar mvar resp
      pure Wai.ResponseReceived
    takeMVar mvar

-------------------------------------------------------------------------------
-- WAI <-> API Gateway
-------------------------------------------------------------------------------

-- | Decode a 'ByteString' into (1) a Wai 'Request' and (2) a filepath where
-- the response should be written.
-- The argument is JSON document with two fields:
--  * @request@: the API Gateway request (see 'parseRequest')
--  * @reqsponseFile@: Where to write the API Gateway response (see
--      'toJSONResponse')
decodeInput :: BS.ByteString -> Either (Aeson.JSONPath, String) (FilePath, IO Wai.Request)
decodeInput = Aeson.eitherDecodeStrictWith Aeson.jsonEOF $ Aeson.iparse $
    Aeson.withObject "input" $ \obj ->
      (,) <$>
        obj .: "responseFile" <*>
        (obj .: "request" >>= parseRequest)

data ApiGatewayRequestV2 = ApiGatewayRequestV2
  { body :: !(Maybe T.Text)
  , headers :: !(HMap.HashMap T.Text T.Text)
  , rawQueryString :: !(Maybe T.Text)
  , requestContext :: !RequestContext
  , cookies :: !(Maybe [T.Text])
  , isBase64Encoded :: !Bool
  }
  deriving (Show, Generic, Aeson.ToJSON, Aeson.FromJSON, NFData)

data RequestContext = RequestContext
  { accountId :: !T.Text
  , apiId :: !T.Text
  , domainName :: !T.Text
  , http :: Http
  , requestId :: !T.Text
  }
  deriving (Show, Generic, Aeson.ToJSON, Aeson.FromJSON, NFData)

data Http = Http
  { method :: !T.Text
  , path :: !T.Text
  , protocol :: !T.Text
  , sourceIp :: !T.Text
  }
  deriving (Show, Generic, Aeson.ToJSON, Aeson.FromJSON, NFData)

-- | Parser for a 'Wai.Request'.
--
-- The input is an AWS API Gateway request event:
-- https://docs.aws.amazon.com/lambda/latest/dg/eventsources.html#eventsources-api-gateway-request
parseRequest :: Aeson.Object -> Aeson.Parser (IO Wai.Request)
parseRequest obj = do
  ApiGatewayRequestV2
    { body
    , headers
    , rawQueryString
    , cookies
    , requestContext = RequestContext
      { http = Http
        { method
        , protocol
        , path
        , sourceIp
        }
      }
    } <- Aeson.parseJSON (Aeson.Object obj)

  -- We don't get data about the version, just assume
  httpVersion <- case CI.mk protocol of
    "http/0.9" -> pure H.http09
    "http/1.0" -> pure H.http10
    "http/1.1" -> pure H.http11
    "http/2.0" -> pure H.http20
    _ -> fail $ "Unknown http protocol " <> T.unpack protocol

  --  "headers": {
  --    "Accept": "text/html,application/xhtml+xml,...",
  --    ...
  --    "X-Forwarded-Proto": "https"
  --  },
  let
    cookieHeaders = (\c -> (H.hCookie, T.encodeUtf8 c)) <$> fromMaybe [] cookies
    otherHeaders = (\(k,v) -> (CI.mk (T.encodeUtf8 k),T.encodeUtf8 v)) <$> HMap.toList headers
    requestHeaders = otherHeaders <> cookieHeaders

  isSecure <- pure $ case lookup "X-Forwarded-Proto" requestHeaders of
    Just "https" -> True
    _ -> False

  let rawPathInfo = T.encodeUtf8 path
  pathInfo <- pure $ H.decodePathSegments rawPathInfo

  remoteHost <- case readMaybe @IP.IP (T.unpack sourceIp) of
    Just (IP.IPv4 ip) -> pure $ Socket.SockAddrInet 0 (IP.toHostAddress ip)
    Just (IP.IPv6 ip) -> pure $ Socket.SockAddrInet6 0 0 (IP.toHostAddress6 ip) 0
    _ -> fail $ "Could not parse ip address: " <> T.unpack sourceIp

  let
    rawQueryStringBytes = maybe "" T.encodeUtf8 rawQueryString
    queryString = H.parseQuery (rawQueryStringBytes)

  -- XXX: default to empty body as Lambda doesn't always set one (e.g. GET
  -- requests)
  let requestBodyRaw = maybe "" T.encodeUtf8 body
  requestBodyLength <- pure $
    Wai.KnownLength $ fromIntegral $ BS.length requestBodyRaw

  vault <- pure $ Vault.insert originalRequestKey obj Vault.empty

  requestHeaderHost <- pure $ lookup "host" requestHeaders
  requestHeaderRange <- pure $ lookup "range" requestHeaders
  requestHeaderReferer <- pure $ lookup "referer" requestHeaders
  requestHeaderUserAgent <- pure $ lookup "User-Agent" requestHeaders

  pure $ do
    requestBodyMVar <- newMVar requestBodyRaw
    let requestBody = do
          tryTakeMVar requestBodyMVar >>= \case
            Just bs -> pure bs
            Nothing -> pure BS.empty

    pure $ Wai.Request
        { requestMethod = T.encodeUtf8 method
        , httpVersion
        , rawPathInfo
        , rawQueryString = rawQueryStringBytes
        , queryString
        , requestBodyLength
        , requestHeaderHost
        , requestHeaderUserAgent
        , requestHeaderRange
        , requestHeaderReferer
        , requestHeaders
        , isSecure
        , remoteHost
        , pathInfo
        , requestBody
        , vault
        }

originalRequestKey :: Vault.Key Aeson.Object
originalRequestKey = unsafePerformIO Vault.newKey
{-# NOINLINE originalRequestKey #-}

-- | Read the status, headers and body of a 'Wai.Response'.
readResponse :: Wai.Response -> IO RawResponse
readResponse (Wai.responseToStream -> (st, hdrs, mkBody)) = do
    body <- mkBody drainBody
    pure (st, hdrs, body)
  where
    drainBody :: Wai.StreamingBody -> IO BS.ByteString
    drainBody body = do
      ioref <- newIORef Binary.empty
      body
        (\b -> atomicModifyIORef ioref (\b' -> (b <> b', ())))
        (pure ())
      BL.toStrict . Binary.toLazyByteString <$> readIORef ioref

-- | Make an API Gateway response from status, headers and body.
-- https://docs.aws.amazon.com/lambda/latest/dg/eventsources.html#eventsources-api-gateway-response
toJSONResponse :: H.Status -> H.ResponseHeaders -> BS.ByteString -> Aeson.Object
toJSONResponse st hdrs body =
  let
    (setCookieHeaders, otherHeaders) = partition (\(k,_) -> k == "set-cookie") hdrs
    Aeson.Object obj =
      Aeson.object
        [ ("statusCode", Aeson.Number (fromIntegral (H.statusCode st)))
        , ("headers", Aeson.toJSON $ HMap.fromList $
            (bimap T.decodeUtf8 T.decodeUtf8 . first CI.original) <$> otherHeaders)
        , ("cookies", Aeson.toJSON (T.decodeUtf8 . snd <$> setCookieHeaders))
        , ("body", Aeson.String (T.decodeUtf8 body))
        ]
  in obj

-------------------------------------------------------------------------------
-- Auxiliary
-------------------------------------------------------------------------------

-- | Atomically write the 'ByteString' to the file.
--
-- Uses @rename(2)@.
writeFileAtomic :: FilePath -> BS.ByteString -> IO ()
writeFileAtomic fp bs =
    Temp.withSystemTempFile "temp-response" $ \tmpFp h -> do
      hClose h
      BS.writeFile tmpFp bs
      renameFile tmpFp fp

-- | @flip fix@
xif :: b -> ((b -> c) -> b -> c) -> c
xif = flip fix

{-# INLINE uncurry3 #-}
uncurry3 :: (a -> b -> c -> d) -> ((a, b, c) -> d)
uncurry3 f ~(a,b,c) = f a b c