{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} module Hasura.Events.HTTP ( HTTP(..) , mkHTTP , mkAnyHTTPPost , mkHTTPMaybe , HTTPErr(..) , runHTTP , default2xxParser , noBody2xxParser , defaultRetryPolicy , defaultRetryFn , defaultParser , defaultParserMaybe , isNetworkError , isNetworkErrorHC , HLogger , mkHLogger , ExtraContext(..) ) where import qualified Control.Retry as R import qualified Data.Aeson as J import qualified Data.Aeson.Casing as J import qualified Data.Aeson.TH as J import qualified Data.ByteString.Lazy as B import qualified Data.CaseInsensitive as CI import qualified Data.TByteString as TBS import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding.Error as TE import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.Encoding as TLE import qualified Data.Time.Clock as Time import qualified Network.HTTP.Client as H import qualified Network.HTTP.Types as N import qualified Network.Wreq as W import qualified Network.Wreq.Session as WS import qualified System.Log.FastLogger as FL import Control.Exception (try) import Control.Lens import Control.Monad.Except (MonadError, throwError) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Reader (MonadReader) import Data.Has import Hasura.Logging -- import Data.Monoid import Hasura.Prelude import Hasura.RQL.Types.Subscribe -- import Context (HTTPSessionMgr (..)) -- import Log type HLogger = (LogLevel, EngineLogType, J.Value) -> IO () data ExtraContext = ExtraContext { elEventCreatedAt :: Time.UTCTime , elEventId :: TriggerId } deriving (Show, Eq) $(J.deriveJSON (J.aesonDrop 2 J.snakeCase){J.omitNothingFields=True} ''ExtraContext) data HTTPErr = HClient !H.HttpException | HParse !N.Status !String | HStatus !N.Status TBS.TByteString | HOther !String deriving (Show) instance J.ToJSON HTTPErr where toJSON err = toObj $ case err of (HClient e) -> ("client", J.toJSON $ show e) (HParse st e) -> ( "parse" , J.toJSON (N.statusCode st, show e) ) (HStatus st resp) -> ("status", J.toJSON (N.statusCode st, resp)) (HOther e) -> ("internal", J.toJSON $ show e) where toObj :: (T.Text, J.Value) -> J.Value toObj (k, v) = J.object [ "type" J..= k , "detail" J..= v] -- encapsulates a http operation instance ToEngineLog HTTPErr where toEngineLog err = (LevelError, "event-trigger", J.toJSON err ) data HTTP a = HTTP { _hMethod :: !String , _hUrl :: !String , _hPayload :: !(Maybe J.Value) , _hFormData :: !(Maybe [W.FormParam]) -- options modifier , _hOptions :: W.Options -> W.Options -- the response parser , _hParser :: W.Response B.ByteString -> Either HTTPErr a -- should the operation be retried , _hRetryFn :: Either HTTPErr a -> Bool -- the retry policy , _hRetryPolicy :: R.RetryPolicyM IO } -- TODO. Why this istance? -- instance Show (HTTP a) where -- show (HTTP m u p _ _ _ _) = show m ++ " " ++ show u ++ " : " ++ show p isNetworkError :: HTTPErr -> Bool isNetworkError = \case HClient he -> isNetworkErrorHC he _ -> False isNetworkErrorHC :: H.HttpException -> Bool isNetworkErrorHC = \case H.HttpExceptionRequest _ (H.ConnectionFailure _) -> True H.HttpExceptionRequest _ H.ConnectionTimeout -> True H.HttpExceptionRequest _ H.ResponseTimeout -> True _ -> False -- retries on the typical network errors defaultRetryFn :: Either HTTPErr a -> Bool defaultRetryFn _ = False -- full jitter backoff defaultRetryPolicy :: (MonadIO m) => R.RetryPolicyM m defaultRetryPolicy = R.capDelay (120 * 1000 * 1000) (R.fullJitterBackoff (2 * 1000 * 1000)) <> R.limitRetries 15 -- a helper function respJson :: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr a respJson resp = either (Left . HParse respCode) return $ J.eitherDecode respBody where respCode = resp ^. W.responseStatus respBody = resp ^. W.responseBody defaultParser :: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr a defaultParser resp = if | respCode == N.status200 -> respJson resp | otherwise -> do let val = TBS.fromLBS $ resp ^. W.responseBody throwError $ HStatus respCode val where respCode = resp ^. W.responseStatus -- like default parser but turns 404 into maybe defaultParserMaybe :: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr (Maybe a) defaultParserMaybe resp = if | respCode == N.status200 -> Just <$> respJson resp | respCode == N.status404 -> return Nothing | otherwise -> do let val = TBS.fromLBS $ resp ^. W.responseBody throwError $ HStatus respCode val where respCode = resp ^. W.responseStatus -- default parser which allows all 2xx responses default2xxParser :: (J.FromJSON a) => W.Response B.ByteString -> Either HTTPErr a default2xxParser resp = if | respCode >= N.status200 && respCode < N.status300 -> respJson resp | otherwise -> do let val = TBS.fromLBS $ resp ^. W.responseBody throwError $ HStatus respCode val where respCode = resp ^. W.responseStatus noBody2xxParser :: W.Response B.ByteString -> Either HTTPErr () noBody2xxParser resp = if | respCode >= N.status200 && respCode < N.status300 -> return () | otherwise -> do let val = TBS.fromLBS $ resp ^. W.responseBody throwError $ HStatus respCode val where respCode = resp ^. W.responseStatus anyBodyParser :: W.Response B.ByteString -> Either HTTPErr B.ByteString anyBodyParser resp = if | respCode >= N.status200 && respCode < N.status300 -> return $ resp ^. W.responseBody | otherwise -> do let val = TBS.fromLBS $ resp ^. W.responseBody throwError $ HStatus respCode val where respCode = resp ^. W.responseStatus mkHTTP :: (J.FromJSON a) => String -> String -> HTTP a mkHTTP method url = HTTP method url Nothing Nothing id defaultParser defaultRetryFn defaultRetryPolicy mkAnyHTTPPost :: String -> Maybe J.Value -> HTTP B.ByteString mkAnyHTTPPost url payload = HTTP "POST" url payload Nothing id anyBodyParser defaultRetryFn defaultRetryPolicy mkHTTPMaybe :: (J.FromJSON a) => String -> String -> HTTP (Maybe a) mkHTTPMaybe method url = HTTP method url Nothing Nothing id defaultParserMaybe defaultRetryFn defaultRetryPolicy -- internal logging related types data HTTPReq = HTTPReq { _hrqMethod :: !String , _hrqUrl :: !String , _hrqPayload :: !(Maybe J.Value) , _hrqTry :: !Int , _hrqDelay :: !(Maybe Int) } deriving (Show, Eq) $(J.deriveJSON (J.aesonDrop 4 J.snakeCase){J.omitNothingFields=True} ''HTTPReq) instance ToEngineLog HTTPReq where toEngineLog req = (LevelInfo, "event-trigger", J.toJSON req ) instance ToEngineLog HTTPResp where toEngineLog resp = (LevelInfo, "event-trigger", J.toJSON resp ) data HTTPResp = HTTPResp { _hrsStatus :: !Int , _hrsHeaders :: ![T.Text] , _hrsBody :: !TL.Text } deriving (Show, Eq) $(J.deriveJSON (J.aesonDrop 4 J.snakeCase){J.omitNothingFields=True} ''HTTPResp) data HTTPRespExtra = HTTPRespExtra { _hreResponse :: HTTPResp , _hreContext :: Maybe ExtraContext } $(J.deriveJSON (J.aesonDrop 4 J.snakeCase){J.omitNothingFields=True} ''HTTPRespExtra) instance ToEngineLog HTTPRespExtra where toEngineLog resp = (LevelInfo, "event-trigger", J.toJSON resp ) mkHTTPResp :: W.Response B.ByteString -> HTTPResp mkHTTPResp resp = HTTPResp (resp ^. W.responseStatus.W.statusCode) (map decodeHeader $ resp ^. W.responseHeaders) (decodeLBS $ resp ^. W.responseBody) where decodeBS = TE.decodeUtf8With TE.lenientDecode decodeLBS = TLE.decodeUtf8With TE.lenientDecode decodeHeader (hdrName, hdrVal) = decodeBS (CI.original hdrName) <> " : " <> decodeBS hdrVal runHTTP :: ( MonadReader r m , MonadError HTTPErr m , MonadIO m , Has WS.Session r , Has HLogger r ) => W.Options -> HTTP a -> Maybe ExtraContext -> m a runHTTP opts http exLog = do -- try the http request res <- R.retrying retryPol' retryFn' $ httpWithLogging opts http exLog -- process the result either throwError return res where retryPol' = R.RetryPolicyM $ liftIO . R.getRetryPolicyM (_hRetryPolicy http) retryFn' _ = return . _hRetryFn http httpWithLogging :: ( MonadReader r m , MonadIO m , Has WS.Session r , Has HLogger r ) => W.Options -> HTTP a -> Maybe ExtraContext -> R.RetryStatus -> m (Either HTTPErr a) -- the actual http action httpWithLogging opts (HTTP method url mPayload mFormParams optsMod bodyParser _ _) exLog retryStatus = do (logF:: HLogger) <- asks getter -- log the request liftIO $ logF $ toEngineLog $ HTTPReq method url mPayload (R.rsIterNumber retryStatus) (R.rsPreviousDelay retryStatus) session <- asks getter res <- finallyRunHTTPPlz session case res of Left e -> liftIO $ logF $ toEngineLog $ HClient e Right resp -> --liftIO $ print "=======================>" liftIO $ logF $ toEngineLog $ HTTPRespExtra (mkHTTPResp resp) exLog --liftIO $ print "<=======================" -- return the processed response return $ either (Left . HClient) bodyParser res where -- set wreq options to ignore status code exceptions ignoreStatusCodeExceptions _ _ = return () finalOpts = optsMod opts & W.checkResponse ?~ ignoreStatusCodeExceptions -- the actual function which makes the relevant Wreq calls finallyRunHTTPPlz sessMgr = liftIO $ try $ case (mPayload, mFormParams) of (Just payload, _) -> WS.customPayloadMethodWith method finalOpts sessMgr url payload (Nothing, Just fps) -> WS.customPayloadMethodWith method finalOpts sessMgr url fps (Nothing, Nothing) -> WS.customMethodWith method finalOpts sessMgr url mkHLogger :: LoggerCtx -> HLogger mkHLogger (LoggerCtx loggerSet serverLogLevel timeGetter) (logLevel, logTy, logDet) = do localTime <- timeGetter when (logLevel >= serverLogLevel) $ FL.pushLogStrLn loggerSet $ FL.toLogStr $ J.encode $ EngineLog localTime logLevel logTy logDet