mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-16 18:42:30 +03:00
664e9df9c6
Remove the Inversion of Control (SuspendRequest) and simplify the tracing of HTTP Requests. Co-authored-by: Phil Freeman <phil@hasura.io>
395 lines
12 KiB
Haskell
395 lines
12 KiB
Haskell
{-|
|
|
= Hasura.Eventing.HTTP
|
|
|
|
This module is an utility module providing HTTP utilities for
|
|
"Hasura.Eventing.EventTriggers" and "Hasura.Eventing.ScheduledTriggers".
|
|
|
|
The event triggers and scheduled triggers share the event delivery
|
|
mechanism using the 'tryWebhook' function defined in this module.
|
|
-}
|
|
module Hasura.Eventing.HTTP
|
|
( HTTPErr(..)
|
|
, HTTPResp(..)
|
|
, tryWebhook
|
|
, runHTTP
|
|
, isNetworkError
|
|
, isNetworkErrorHC
|
|
, logHTTPForET
|
|
, logHTTPForST
|
|
, ExtraLogContext(..)
|
|
, RequestDetails (..)
|
|
, EventId
|
|
, Invocation(..)
|
|
, InvocationVersion
|
|
, Response(..)
|
|
, WebhookRequest(..)
|
|
, WebhookResponse(..)
|
|
, ClientError(..)
|
|
, isClientError
|
|
, mkClientErr
|
|
, mkWebhookReq
|
|
, mkResp
|
|
, LogEnvHeaders
|
|
, encodeHeader
|
|
, decodeHeader
|
|
, getRetryAfterHeaderFromHTTPErr
|
|
, getRetryAfterHeaderFromResp
|
|
, parseRetryHeaderValue
|
|
, TriggerTypes(..)
|
|
, invocationVersionET
|
|
, invocationVersionST
|
|
) where
|
|
|
|
import qualified Data.ByteString as BS
|
|
import qualified Data.ByteString.Lazy as LBS
|
|
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.Time.Clock as Time
|
|
import qualified Network.HTTP.Client as HTTP
|
|
import qualified Network.HTTP.Types as HTTP
|
|
|
|
import Control.Exception (try)
|
|
import Data.Aeson
|
|
import Data.Aeson.Casing
|
|
import Data.Aeson.TH
|
|
import Data.Either
|
|
import Data.Has
|
|
import Data.Int (Int64)
|
|
import Hasura.Logging
|
|
import Hasura.Prelude
|
|
import Hasura.RQL.DDL.Headers
|
|
import Hasura.RQL.Types.EventTrigger
|
|
import Hasura.Tracing
|
|
|
|
type LogEnvHeaders = Bool
|
|
|
|
retryAfterHeader :: CI.CI T.Text
|
|
retryAfterHeader = "Retry-After"
|
|
|
|
data WebhookRequest
|
|
= WebhookRequest
|
|
{ _rqPayload :: Value
|
|
, _rqHeaders :: [HeaderConf]
|
|
, _rqVersion :: T.Text
|
|
}
|
|
$(deriveToJSON (aesonDrop 3 snakeCase){omitNothingFields=True} ''WebhookRequest)
|
|
|
|
data WebhookResponse
|
|
= WebhookResponse
|
|
{ _wrsBody :: TBS.TByteString
|
|
, _wrsHeaders :: [HeaderConf]
|
|
, _wrsStatus :: Int
|
|
}
|
|
$(deriveToJSON (aesonDrop 4 snakeCase){omitNothingFields=True} ''WebhookResponse)
|
|
|
|
newtype ClientError = ClientError { _ceMessage :: TBS.TByteString}
|
|
$(deriveToJSON (aesonDrop 3 snakeCase){omitNothingFields=True} ''ClientError)
|
|
|
|
type InvocationVersion = T.Text
|
|
|
|
invocationVersionET :: InvocationVersion
|
|
invocationVersionET = "2"
|
|
|
|
invocationVersionST :: InvocationVersion
|
|
invocationVersionST = "1"
|
|
|
|
-- | There are two types of events: EventType (for event triggers) and ScheduledType (for scheduled triggers)
|
|
data TriggerTypes = EventType | ScheduledType
|
|
|
|
data Response (a :: TriggerTypes) =
|
|
ResponseHTTP WebhookResponse | ResponseError ClientError
|
|
|
|
instance ToJSON (Response 'EventType) where
|
|
toJSON (ResponseHTTP resp) = object
|
|
[ "type" .= String "webhook_response"
|
|
, "data" .= toJSON resp
|
|
, "version" .= invocationVersionET
|
|
]
|
|
toJSON (ResponseError err) = object
|
|
[ "type" .= String "client_error"
|
|
, "data" .= toJSON err
|
|
, "version" .= invocationVersionET
|
|
]
|
|
|
|
instance ToJSON (Response 'ScheduledType) where
|
|
toJSON (ResponseHTTP resp) = object
|
|
[ "type" .= String "webhook_response"
|
|
, "data" .= toJSON resp
|
|
, "version" .= invocationVersionST
|
|
]
|
|
toJSON (ResponseError err) = object
|
|
[ "type" .= String "client_error"
|
|
, "data" .= toJSON err
|
|
, "version" .= invocationVersionST
|
|
]
|
|
|
|
|
|
data Invocation (a :: TriggerTypes)
|
|
= Invocation
|
|
{ iEventId :: EventId
|
|
, iStatus :: Int
|
|
, iRequest :: WebhookRequest
|
|
, iResponse :: (Response a)
|
|
}
|
|
|
|
data ExtraLogContext
|
|
= ExtraLogContext
|
|
{ elEventCreatedAt :: Maybe Time.UTCTime
|
|
, elEventId :: EventId
|
|
} deriving (Show, Eq)
|
|
|
|
$(deriveJSON (aesonDrop 2 snakeCase){omitNothingFields=True} ''ExtraLogContext)
|
|
|
|
data HTTPResp (a :: TriggerTypes)
|
|
= HTTPResp
|
|
{ hrsStatus :: !Int
|
|
, hrsHeaders :: ![HeaderConf]
|
|
, hrsBody :: !TBS.TByteString
|
|
, hrsSize :: !Int64
|
|
} deriving (Show, Eq)
|
|
|
|
$(deriveToJSON (aesonDrop 3 snakeCase){omitNothingFields=True} ''HTTPResp)
|
|
|
|
instance ToEngineLog (HTTPResp 'EventType) Hasura where
|
|
toEngineLog resp = (LevelInfo, eventTriggerLogType, toJSON resp)
|
|
|
|
instance ToEngineLog (HTTPResp 'ScheduledType) Hasura where
|
|
toEngineLog resp = (LevelInfo, scheduledTriggerLogType, toJSON resp)
|
|
|
|
data HTTPErr (a :: TriggerTypes)
|
|
= HClient !HTTP.HttpException
|
|
| HParse !HTTP.Status !String
|
|
| HStatus !(HTTPResp a)
|
|
| HOther !String
|
|
deriving (Show)
|
|
|
|
instance ToJSON (HTTPErr a) where
|
|
toJSON err = toObj $ case err of
|
|
(HClient e) -> ("client", toJSON $ show e)
|
|
(HParse st e) ->
|
|
( "parse"
|
|
, toJSON (HTTP.statusCode st, show e)
|
|
)
|
|
(HStatus resp) ->
|
|
("status", toJSON resp)
|
|
(HOther e) -> ("internal", toJSON $ show e)
|
|
where
|
|
toObj :: (T.Text, Value) -> Value
|
|
toObj (k, v) = object [ "type" .= k
|
|
, "detail" .= v]
|
|
|
|
instance ToEngineLog (HTTPErr 'EventType) Hasura where
|
|
toEngineLog err = (LevelError, eventTriggerLogType, toJSON err)
|
|
|
|
instance ToEngineLog (HTTPErr 'ScheduledType) Hasura where
|
|
toEngineLog err = (LevelError, scheduledTriggerLogType, toJSON err)
|
|
|
|
mkHTTPResp :: HTTP.Response LBS.ByteString -> HTTPResp a
|
|
mkHTTPResp resp =
|
|
HTTPResp
|
|
{ hrsStatus = HTTP.statusCode $ HTTP.responseStatus resp
|
|
, hrsHeaders = map decodeHeader $ HTTP.responseHeaders resp
|
|
, hrsBody = TBS.fromLBS respBody
|
|
, hrsSize = LBS.length respBody
|
|
}
|
|
where
|
|
respBody = HTTP.responseBody resp
|
|
decodeBS = TE.decodeUtf8With TE.lenientDecode
|
|
decodeHeader (hdrName, hdrVal)
|
|
= HeaderConf (decodeBS $ CI.original hdrName) (HVValue (decodeBS hdrVal))
|
|
|
|
newtype RequestDetails
|
|
= RequestDetails { _rdSize :: Int64 }
|
|
$(deriveToJSON (aesonDrop 3 snakeCase) ''RequestDetails)
|
|
|
|
data HTTPRespExtra (a :: TriggerTypes)
|
|
= HTTPRespExtra
|
|
{ _hreResponse :: !(Either (HTTPErr a) (HTTPResp a))
|
|
, _hreContext :: !ExtraLogContext
|
|
, _hreRequest :: !RequestDetails
|
|
}
|
|
|
|
instance ToJSON (HTTPRespExtra a) where
|
|
toJSON (HTTPRespExtra resp ctxt req) =
|
|
case resp of
|
|
Left errResp ->
|
|
object [ "response" .= toJSON errResp
|
|
, "request" .= toJSON req
|
|
, "context" .= toJSON ctxt
|
|
]
|
|
Right rsp ->
|
|
object [ "response" .= toJSON rsp
|
|
, "request" .= toJSON req
|
|
, "context" .= toJSON ctxt
|
|
]
|
|
|
|
instance ToEngineLog (HTTPRespExtra 'EventType) Hasura where
|
|
toEngineLog resp = (LevelInfo, eventTriggerLogType, toJSON resp)
|
|
|
|
instance ToEngineLog (HTTPRespExtra 'ScheduledType) Hasura where
|
|
toEngineLog resp = (LevelInfo, scheduledTriggerLogType, toJSON resp)
|
|
|
|
isNetworkError :: HTTPErr a -> Bool
|
|
isNetworkError = \case
|
|
HClient he -> isNetworkErrorHC he
|
|
_ -> False
|
|
|
|
isNetworkErrorHC :: HTTP.HttpException -> Bool
|
|
isNetworkErrorHC = \case
|
|
HTTP.HttpExceptionRequest _ (HTTP.ConnectionFailure _) -> True
|
|
HTTP.HttpExceptionRequest _ HTTP.ConnectionTimeout -> True
|
|
HTTP.HttpExceptionRequest _ HTTP.ResponseTimeout -> True
|
|
_ -> False
|
|
|
|
anyBodyParser :: HTTP.Response LBS.ByteString -> Either (HTTPErr a) (HTTPResp a)
|
|
anyBodyParser resp = do
|
|
let httpResp = mkHTTPResp resp
|
|
if respCode >= HTTP.status200 && respCode < HTTP.status300
|
|
then return httpResp
|
|
else throwError $ HStatus httpResp
|
|
where
|
|
respCode = HTTP.responseStatus resp
|
|
|
|
data HTTPReq
|
|
= HTTPReq
|
|
{ _hrqMethod :: !String
|
|
, _hrqUrl :: !String
|
|
, _hrqPayload :: !(Maybe Value)
|
|
, _hrqTry :: !Int
|
|
, _hrqDelay :: !(Maybe Int)
|
|
} deriving (Show, Eq)
|
|
|
|
$(deriveJSON (aesonDrop 4 snakeCase){omitNothingFields=True} ''HTTPReq)
|
|
|
|
instance ToEngineLog HTTPReq Hasura where
|
|
toEngineLog req = (LevelInfo, eventTriggerLogType, toJSON req)
|
|
|
|
logHTTPForET
|
|
:: ( MonadReader r m
|
|
, Has (Logger Hasura) r
|
|
, MonadIO m
|
|
)
|
|
=> Either (HTTPErr 'EventType) (HTTPResp 'EventType)
|
|
-> ExtraLogContext
|
|
-> RequestDetails
|
|
-> m ()
|
|
logHTTPForET eitherResp extraLogCtx reqDetails = do
|
|
logger :: Logger Hasura <- asks getter
|
|
unLogger logger $ HTTPRespExtra eitherResp extraLogCtx reqDetails
|
|
|
|
logHTTPForST
|
|
:: ( MonadReader r m
|
|
, Has (Logger Hasura) r
|
|
, MonadIO m
|
|
)
|
|
=> Either (HTTPErr 'ScheduledType) (HTTPResp 'ScheduledType)
|
|
-> ExtraLogContext
|
|
-> RequestDetails
|
|
-> m ()
|
|
logHTTPForST eitherResp extraLogCtx reqDetails = do
|
|
logger :: Logger Hasura <- asks getter
|
|
unLogger logger $ HTTPRespExtra eitherResp extraLogCtx reqDetails
|
|
|
|
runHTTP :: (MonadIO m) => HTTP.Manager -> HTTP.Request -> m (Either (HTTPErr a) (HTTPResp a))
|
|
runHTTP manager req = do
|
|
res <- liftIO $ try $ HTTP.httpLbs req manager
|
|
return $ either (Left . HClient) anyBodyParser res
|
|
|
|
tryWebhook ::
|
|
( MonadReader r m
|
|
, Has HTTP.Manager r
|
|
, MonadIO m
|
|
, MonadError (HTTPErr a) m
|
|
, MonadTrace m
|
|
)
|
|
=> [HTTP.Header]
|
|
-> HTTP.ResponseTimeout
|
|
-> LBS.ByteString
|
|
-- ^ the request body. It is passed as a 'BL.Bytestring' because we need to
|
|
-- log the request size. As the logging happens outside the function, we pass
|
|
-- it the final request body, instead of 'Value'
|
|
-> String
|
|
-> m (HTTPResp a)
|
|
tryWebhook headers timeout payload webhook = do
|
|
initReqE <- liftIO $ try $ HTTP.parseRequest webhook
|
|
manager <- asks getter
|
|
case initReqE of
|
|
Left excp -> throwError $ HClient excp
|
|
Right initReq -> do
|
|
let req =
|
|
initReq
|
|
{ HTTP.method = "POST"
|
|
, HTTP.requestHeaders = headers
|
|
, HTTP.requestBody = HTTP.RequestBodyLBS payload
|
|
, HTTP.responseTimeout = timeout
|
|
}
|
|
tracedHttpRequest req $ \req' -> do
|
|
eitherResp <- runHTTP manager req'
|
|
onLeft eitherResp throwError
|
|
|
|
mkResp :: Int -> TBS.TByteString -> [HeaderConf] -> Response a
|
|
mkResp status payload headers =
|
|
let wr = WebhookResponse payload headers status
|
|
in ResponseHTTP wr
|
|
|
|
mkClientErr :: TBS.TByteString -> Response a
|
|
mkClientErr message =
|
|
let cerr = ClientError message
|
|
in ResponseError cerr
|
|
|
|
mkWebhookReq :: Value -> [HeaderConf] -> InvocationVersion -> WebhookRequest
|
|
mkWebhookReq payload headers = WebhookRequest payload headers
|
|
|
|
isClientError :: Int -> Bool
|
|
isClientError status = status >= 1000
|
|
|
|
encodeHeader :: EventHeaderInfo -> HTTP.Header
|
|
encodeHeader (EventHeaderInfo hconf cache) =
|
|
let (HeaderConf name _) = hconf
|
|
ciname = CI.mk $ TE.encodeUtf8 name
|
|
value = TE.encodeUtf8 cache
|
|
in (ciname, value)
|
|
|
|
decodeHeader
|
|
:: LogEnvHeaders -> [EventHeaderInfo] -> (HTTP.HeaderName, BS.ByteString)
|
|
-> HeaderConf
|
|
decodeHeader logenv headerInfos (hdrName, hdrVal)
|
|
= let name = decodeBS $ CI.original hdrName
|
|
getName ehi = let (HeaderConf name' _) = ehiHeaderConf ehi
|
|
in name'
|
|
mehi = find (\hi -> getName hi == name) headerInfos
|
|
in case mehi of
|
|
Nothing -> HeaderConf name (HVValue (decodeBS hdrVal))
|
|
Just ehi -> if logenv
|
|
then HeaderConf name (HVValue (ehiCachedValue ehi))
|
|
else ehiHeaderConf ehi
|
|
where
|
|
decodeBS = TE.decodeUtf8With TE.lenientDecode
|
|
|
|
getRetryAfterHeaderFromHTTPErr :: HTTPErr a -> Maybe Text
|
|
getRetryAfterHeaderFromHTTPErr (HStatus resp) = getRetryAfterHeaderFromResp resp
|
|
getRetryAfterHeaderFromHTTPErr _ = Nothing
|
|
|
|
getRetryAfterHeaderFromResp :: HTTPResp a -> Maybe Text
|
|
getRetryAfterHeaderFromResp resp =
|
|
let mHeader =
|
|
find
|
|
(\(HeaderConf name _) -> CI.mk name == retryAfterHeader)
|
|
(hrsHeaders resp)
|
|
in case mHeader of
|
|
Just (HeaderConf _ (HVValue value)) -> Just value
|
|
_ -> Nothing
|
|
|
|
parseRetryHeaderValue :: T.Text -> Maybe Int
|
|
parseRetryHeaderValue hValue =
|
|
let seconds = readMaybe $ T.unpack hValue
|
|
in case seconds of
|
|
Nothing -> Nothing
|
|
Just sec ->
|
|
if sec > 0
|
|
then Just sec
|
|
else Nothing
|