wai-lambda-0.1.0.0: src/Network/Wai/Handler/Lambda.hs
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ViewPatterns #-}
module Network.Wai.Handler.Lambda where
import Control.Concurrent (forkIO)
import Control.Monad
import Data.Aeson ((.:), (.:?), (.!=))
import Data.Bifunctor
import Data.Function (fix)
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
type RawResponse = (H.Status, H.ResponseHeaders, BS.ByteString)
data Settings = Settings
{ timeoutValue :: Int
, handleTimeout :: BS.ByteString -> IO RawResponse
, handleException :: BS.ByteString -> SomeException -> IO RawResponse
}
-- | 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)
-- | 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.Value -> Aeson.Parser (IO Wai.Request)
parseRequest = Aeson.withObject "request" $ \obj -> do
-- "httpMethod": "GET"
requestMethod <- obj .: "httpMethod" >>=
Aeson.withText "requestMethod" (pure . T.encodeUtf8)
-- We don't get data about the version, just assume
httpVersion <- pure H.http11
-- "queryStringParameters": {
-- "name": "me"
-- },
-- XXX: default to empty object for the query params as Lambda doesn't set
-- 'queryStringParameters' if there are no query parameters
queryParams <- obj .:? "queryStringParameters" .!= Aeson.Object HMap.empty >>=
Aeson.withObject "queryParams" (
fmap
(fmap (first T.encodeUtf8) . HMap.toList ) .
traverse (Aeson.withText "queryParam" (pure . T.encodeUtf8))
)
rawQueryString <- pure $ H.renderSimpleQuery True queryParams
-- "path": "/test/hello",
path <- obj .: "path" >>=
Aeson.withText "path" (pure . T.encodeUtf8)
rawPathInfo <- pure $ path <> rawQueryString
-- "headers": {
-- "Accept": "text/html,application/xhtml+xml,...",
-- ...
-- "X-Forwarded-Proto": "https"
-- },
requestHeaders <- obj .: "headers" >>=
Aeson.withObject "headers" (
fmap
(fmap (first (CI.mk . T.encodeUtf8)) . HMap.toList) .
traverse (Aeson.withText "header" (pure . T.encodeUtf8))
)
isSecure <- pure $ case lookup "X-Forwarded-Proto" requestHeaders of
Just "https" -> True
_ -> False
-- "requestContext": {
-- ...
-- "identity": {
-- ...
-- "sourceIp": "192.168.100.1",
-- },
-- ...
-- },
remoteHost <- obj .: "requestContext" >>=
Aeson.withObject "requestContext" (\obj' ->
obj' .: "identity" >>=
Aeson.withObject "identity" (\idt -> do
sourceIp <- case HMap.lookup "sourceIp" idt of
Nothing -> fail "no sourceIp"
Just (Aeson.String x) -> pure $ T.unpack x
Just _ -> fail "bad type for sourceIp"
ip <- case readMaybe sourceIp of
Just ip -> pure ip
Nothing -> fail "cannot parse sourceIp"
pure $ case ip of
IP.IPv4 ip4 ->
Socket.SockAddrInet
0 -- default port
(IP.toHostAddress ip4)
IP.IPv6 ip6 ->
Socket.SockAddrInet6
0 -- default port
0 -- flow info
(IP.toHostAddress6 ip6)
0 -- scope id
)
)
pathInfo <- pure $ H.decodePathSegments path
queryString <- pure $ H.parseQuery rawQueryString
-- XXX: default to empty body as Lambda doesn't always set one (e.g. GET
-- requests)
requestBodyRaw <- obj .:? "body" .!= Aeson.String "" >>=
Aeson.withText "body" (pure . T.encodeUtf8)
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 {..}
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 = HMap.fromList
[ ("statusCode", Aeson.Number (fromIntegral (H.statusCode st)))
, ("headers", Aeson.toJSON $ HMap.fromList $
(bimap T.decodeUtf8 T.decodeUtf8 . first CI.original) <$> hdrs)
, ("body", Aeson.String (T.decodeUtf8 body))
]
-------------------------------------------------------------------------------
-- 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