praxo-core-0.3.0: src/Praxo/Harmovela.hs
module Praxo.Harmovela
( HarmovelaRequest(..)
, HarmovelaResponse(..)
, HarmovelaTransport(..)
, HarmovelaSink(..)
, newHarmovelaSink
, buildHarmovelaRequest
, emitHarmovelaEvent
, formatHarmovelaTimestamp
) where
import Data.Char (isSpace)
import Praxo.Agent (AgentError(..))
import Praxo.Json (Json(..), encodeJson)
import Praxo.Runtime (Event(..))
data HarmovelaRequest = HarmovelaRequest
{ harmovelaMethod :: String
, harmovelaURL :: String
, harmovelaHeaders :: [(String, String)]
, harmovelaBody :: String
} deriving (Eq, Show)
data HarmovelaResponse = HarmovelaResponse
{ harmovelaStatus :: Int
, harmovelaResponseBody :: String
} deriving (Eq, Show)
newtype HarmovelaTransport = HarmovelaTransport
{ sendHarmovela :: HarmovelaRequest -> IO (Either AgentError HarmovelaResponse)
}
data HarmovelaSink = HarmovelaSink
{ harmovelaBaseURL :: String
, harmovelaSource :: String
, harmovelaSpecVersion :: String
, harmovelaClock :: IO Integer
, harmovelaTransport :: HarmovelaTransport
}
newHarmovelaSink :: String -> HarmovelaTransport -> IO Integer -> HarmovelaSink
newHarmovelaSink base transport clock = HarmovelaSink
{ harmovelaBaseURL = base
, harmovelaSource = "praxo-core"
, harmovelaSpecVersion = "0.2"
, harmovelaClock = clock
, harmovelaTransport = transport
}
buildHarmovelaRequest :: HarmovelaSink -> Event -> IO (Either AgentError HarmovelaRequest)
buildHarmovelaRequest sink event
| null (harmovelaBaseURL sink) =
pure (Left (AgentError "HARMOVELA_URL" "harmovela: base URL is required"))
| otherwise = do
nanos <- harmovelaClock sink
case encodeJson (envelope sink event nanos) of
Left detail -> pure (Left (AgentError "HARMOVELA_ENVELOPE" detail))
Right body -> pure (Right HarmovelaRequest
{ harmovelaMethod = "POST"
, harmovelaURL = trimSlash (harmovelaBaseURL sink) ++ "/events"
, harmovelaHeaders = [("content-type", "application/json")]
, harmovelaBody = body
})
emitHarmovelaEvent :: HarmovelaSink -> Event -> IO (Either AgentError ())
emitHarmovelaEvent sink event = do
request <- buildHarmovelaRequest sink event
case request of
Left err -> pure (Left err)
Right httpRequest -> do
response <- sendHarmovela (harmovelaTransport sink) httpRequest
pure $ case response of
Left err -> Left err
Right value
| harmovelaStatus value >= 200 && harmovelaStatus value < 300 -> Right ()
| otherwise -> Left (AgentError "HARMOVELA_HTTP"
("harmovela: HTTP " ++ show (harmovelaStatus value) ++ ": " ++ bounded (harmovelaResponseBody value)))
envelope :: HarmovelaSink -> Event -> Integer -> Json
envelope sink event nanos = JObject
[ ("spec_version", JString (specVersion sink))
, ("id", JString (eventID event nanos))
, ("type", JString (eventKind event))
, ("source", JString (source sink))
, ("created_at", JString (formatHarmovelaTimestamp nanos))
, ("payload", JObject (payload event))
]
payload :: Event -> [(String, Json)]
payload event =
let base = case eventData event of
JObject fields -> fields
_ -> []
in apply "session_id" (maybeValue (eventSessionId event)) (apply "run_id" (maybeValue =<< eventRunId event) base)
maybeValue :: String -> Maybe String
maybeValue "" = Nothing
maybeValue value = Just value
apply :: String -> Maybe String -> [(String, Json)] -> [(String, Json)]
apply _ Nothing fields = fields
apply key (Just value) fields = filter ((/= key) . fst) fields ++ [(key, JString value)]
eventID :: Event -> Integer -> String
eventID event nanos =
let prefix = case eventRunId event of
Just run | not (null run) -> run
_ -> if null (eventSessionId event) then "event" else eventSessionId event
in prefix ++ "-" ++ show nanos
source :: HarmovelaSink -> String
source sink = if null (harmovelaSource sink) then "praxo-core" else harmovelaSource sink
specVersion :: HarmovelaSink -> String
specVersion sink = if null (harmovelaSpecVersion sink) then "0.2" else harmovelaSpecVersion sink
formatHarmovelaTimestamp :: Integer -> String
formatHarmovelaTimestamp nanos =
let (seconds, fraction) = nanos `divMod` 1000000000
(days, daySeconds) = seconds `divMod` 86400
(year, month, day) = civilFromDays days
hour = daySeconds `div` 3600
minute = (daySeconds `mod` 3600) `div` 60
second = daySeconds `mod` 60
in pad 4 year ++ "-" ++ pad 2 month ++ "-" ++ pad 2 day ++ "T" ++
pad 2 hour ++ ":" ++ pad 2 minute ++ ":" ++ pad 2 second ++ "." ++
pad 9 fraction ++ "Z"
civilFromDays :: Integer -> (Integer, Integer, Integer)
civilFromDays offset =
let z = offset + 719468
era = if z >= 0 then z `div` 146097 else (z - 146096) `div` 146097
doe = z - era * 146097
yoe = (doe - doe `div` 1460 + doe `div` 36524 - doe `div` 146096) `div` 365
year = yoe + era * 400
doy = doe - (365 * yoe + yoe `div` 4 - yoe `div` 100)
mp = (5 * doy + 2) `div` 153
day = doy - (153 * mp + 2) `div` 5 + 1
month = if mp < 10 then mp + 3 else mp - 9
in (if month <= 2 then year + 1 else year, month, day)
pad :: Int -> Integer -> String
pad width value =
let text = show value
in replicate (max 0 (width - length text)) '0' ++ text
trimSlash :: String -> String
trimSlash = reverse . dropWhile (== '/') . reverse
bounded :: String -> String
bounded text =
let trimmed = trim text
in if length trimmed > 512 then take 512 trimmed else trimmed
trim :: String -> String
trim = dropWhileEnd isSpace . dropWhile isSpace
dropWhileEnd :: (a -> Bool) -> [a] -> [a]
dropWhileEnd predicate = reverse . dropWhile predicate . reverse