okapi-0.1.0.0: src/Okapi/Function.hs
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE UndecidableInstances #-}
module Okapi.Function
( -- FOR RUNNING OKAPI
runOkapi,
runOkapiTLS,
makeOkapiApp,
-- METHOD PARSERS
get,
post,
head,
put,
delete,
trace,
connect,
options,
patch,
-- PATH PARSERS
seg,
segs,
segParam,
segWith,
path,
-- QUERY PARAM PARSERS
queryParam,
queryFlag,
-- HEADER PARSERS
header,
auth,
basicAuth,
-- BODY PARSERS
bodyJSON,
bodyForm,
-- RESPOND FUNCTIONS
okPlainText,
okJSON,
okHTML,
okLucid,
connectEventSource,
noContent,
file,
okFile,
-- FAILURE FUNCTIONS
skip,
error,
error500,
error401,
error403,
error404,
error422,
-- ERROR HANDLING
(<!>),
optionalError,
optionError,
)
where
import qualified Control.Concurrent as Concurrent
import qualified Control.Concurrent.STM.TVar as TVar
import qualified Control.Monad.Except as Except
import qualified Control.Monad.IO.Class as IO
import qualified Control.Monad.Morph as Morph
import qualified Control.Monad.State.Class as State
import qualified Control.Monad.Trans.Except
import qualified Control.Monad.Trans.Except as ExceptT
import qualified Control.Monad.Trans.State.Strict as StateT
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Encoding as Aeson
import qualified Data.Bifunctor as Bifunctor
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy as LazyByteString
import qualified Data.Foldable as Foldable
import qualified Data.List as List
import qualified Data.Maybe as Maybe
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import qualified GHC.Natural as Natural
import qualified Lucid
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import qualified Network.Wai.Handler.Warp as Warp
import qualified Network.Wai.Handler.WarpTLS as Warp
import qualified Network.Wai.Internal as Wai
import Network.Wai.Middleware.Gzip (gzip, def)
import qualified Okapi.EventSource as EventSource
import Okapi.Type
( Failure (Error, Skip),
Headers,
MonadOkapi,
OkapiT (..),
QueryItem,
Request (..),
Response (..),
File (..),
Result (..),
State (..),
)
import qualified Web.FormUrlEncoded as Web
import qualified Web.HttpApiData as Web
import Prelude hiding (error, head)
-- FOR RUNNING OKAPI
runOkapi :: Monad m => (forall a. m a -> IO a) -> Int -> OkapiT m Result -> IO ()
runOkapi hoister port okapiT = do
print $ "Running Okapi App on port " <> show port
Warp.run port $ makeOkapiApp hoister okapiT
runOkapiTLS :: Monad m => (forall a. m a -> IO a) -> Warp.TLSSettings -> Warp.Settings -> OkapiT m Result -> IO ()
runOkapiTLS hoister tlsSettings settings okapiT = do
print "Running servo on port 43"
Warp.runTLS tlsSettings settings $ makeOkapiApp hoister okapiT
makeOkapiApp :: Monad m => (forall a. m a -> IO a) -> OkapiT m Result -> Wai.Application
makeOkapiApp hoister okapiT waiRequest respond = do
(eitherFailureOrResult, _state) <- (StateT.runStateT . ExceptT.runExceptT . unOkapiT $ Morph.hoist hoister okapiT) (waiRequestToState {-eventSourcePoolTVar-} waiRequest)
case eitherFailureOrResult of
Left Skip -> respond $ Wai.responseLBS HTTP.status404 [] "Not Found"
Left (Error response) -> respond . responseToWaiResponse $ response
Right (ResultResponse response) -> respond . responseToWaiResponse $ response
Right (ResultFile file) -> respond . fileToWaiResponse $ file
Right (ResultEventSource eventSource) -> (gzip def $ EventSource.eventSourceAppUnagiChan eventSource) waiRequest respond
waiRequestToState :: Wai.Request -> State
waiRequestToState waiRequest =
let requestMethod = Wai.requestMethod waiRequest
requestPath = Wai.pathInfo waiRequest
requestQuery = HTTP.queryToQueryText $ Wai.queryString waiRequest
requestBody = Wai.strictRequestBody waiRequest
requestHeaders = Wai.requestHeaders waiRequest
requestVault = Wai.vault waiRequest
stateRequest = Request {..}
stateRequestMethodParsed = False
stateRequestBodyParsed = False
in State {..}
responseToWaiResponse :: Response -> Wai.Response
responseToWaiResponse Response {..} = Wai.responseLBS (toEnum $ fromEnum responseStatus) responseHeaders responseBody
fileToWaiResponse :: File -> Wai.Response
fileToWaiResponse File {..} = Wai.responseFile (toEnum $ fromEnum fileStatus) fileHeaders filePath Nothing
-- PARSING METHODS
get :: forall m. MonadOkapi m => m ()
get = method HTTP.methodGet
post :: forall m. MonadOkapi m => m ()
post = method HTTP.methodPost
head :: forall m. MonadOkapi m => m ()
head = method HTTP.methodHead
put :: forall m. MonadOkapi m => m ()
put = method HTTP.methodPut
delete :: forall m. MonadOkapi m => m ()
delete = method HTTP.methodDelete
trace :: forall m. MonadOkapi m => m ()
trace = method HTTP.methodTrace
connect :: forall m. MonadOkapi m => m ()
connect = method HTTP.methodConnect
options :: forall m. MonadOkapi m => m ()
options = method HTTP.methodOptions
patch :: forall m. MonadOkapi m => m ()
patch = method HTTP.methodPatch
method :: forall m. MonadOkapi m => HTTP.Method -> m ()
method method = do
IO.liftIO $ print $ "Attempting to parse method: " <> Text.decodeUtf8 method
state <- State.get
logic state
where
logic :: State -> m ()
logic state
| isMethodParsed state = Except.throwError Skip
| not $ methodMatches state method = Except.throwError Skip
| otherwise = do
IO.liftIO $ print $ "Method parsed: " <> Text.decodeUtf8 method
State.put $ methodParsed state
pure ()
-- PARSING PATHS
-- | Parses a single path segment matching the given text and discards it
seg :: forall m. MonadOkapi m => Text.Text -> m ()
seg goal = segWith (goal ==)
-- | Parses mutiple segments matching the order of the given list and discards them
-- | TODO: Needs testing. May not have the correct behavior
segs :: forall m. MonadOkapi m => [Text.Text] -> m ()
segs = mapM_ seg
segWith :: forall m. MonadOkapi m => (Text.Text -> Bool) -> m ()
segWith predicate = do
IO.liftIO $ print "Attempting to parse seg"
state <- State.get
logic state
where
logic :: State -> m ()
logic state
| not $ segMatches state predicate = do
IO.liftIO $ print "Couldn't match seg"
Except.throwError Skip
| otherwise = do
IO.liftIO $ print $ "Path parsed: " <> show (getSeg state)
State.put $ segParsed state
pure ()
-- | TODO: Change Read a constraint to custom typeclass or FromHTTPApiData
-- | Parses a single seg segment, and returns the parsed seg segment as a value of the given type
segParam :: forall a m. (MonadOkapi m, Web.FromHttpApiData a) => m a
segParam = do
IO.liftIO $ print "Attempting to get param from seg"
state <- State.get
logic state
where
logic :: State -> m a
logic state =
case getSeg state >>= Web.parseUrlPieceMaybe of
Nothing -> Except.throwError Skip
Just value -> do
IO.liftIO $ print "Path param parsed"
State.put $ segParsed state
pure value
-- | Matches entire remaining path or fails
path :: forall m. MonadOkapi m => [Text.Text] -> m ()
path pathMatch = do
state <- State.get
logic state
where
logic :: State -> m ()
logic state
| getPath state /= pathMatch = Except.throwError Skip
| otherwise = do
State.put $ pathParsed state
pure ()
-- PARSING QUERY PARAMETERS
-- | Parses a query parameter with the given name and returns the value as the given type
queryParam :: forall a m. (MonadOkapi m, Web.FromHttpApiData a) => Text.Text -> m a
queryParam key = do
IO.liftIO $ print $ "Attempting to get query param " <> key
state <- State.get
logic state
where
logic :: State -> m a
logic state
| not $ isMethodParsed state = Except.throwError Skip
| not $ isPathParsed state = Except.throwError Skip
| otherwise =
case getQueryItem state (key ==) of
Nothing -> Except.throwError Skip
Just queryItem -> case queryItem of
(_, Nothing) ->
Except.throwError Skip
(_, Just param) -> case Web.parseQueryParamMaybe param of
Nothing ->
Except.throwError Skip
Just value -> do
IO.liftIO $ print $ "Query param parsed: " <> "(" <> key <> "," <> param <> ")"
State.put $ queryParamParsed state queryItem
pure value
queryFlag :: forall m. MonadOkapi m => Text.Text -> m Bool
queryFlag key = do
IO.liftIO $ print $ "Checking if query param exists " <> key
state <- State.get
logic state
where
logic :: State -> m Bool
logic state
| not $ isMethodParsed state = Except.throwError Skip
| not $ isPathParsed state = Except.throwError Skip
| otherwise =
case getQueryItem state (key ==) of
Nothing -> pure False
Just queryItem -> do
IO.liftIO $ print $ "Query param exists: " <> key
State.put $ queryParamParsed state queryItem
pure True
-- PARSING HEADERS
header :: forall m. MonadOkapi m => HTTP.HeaderName -> m Text.Text
header headerName = do
state <- State.get
logic state
where
logic :: State -> m Text.Text
logic state =
case getHeader state headerName of
Nothing -> Except.throwError Skip
Just header@(name, value) -> pure $ Text.decodeUtf8 value
auth :: forall m. MonadOkapi m => m Text.Text
auth = header "Authorization"
basicAuth :: forall m. MonadOkapi m => m (Text.Text, Text.Text)
basicAuth = do
IO.liftIO $ print "Attempting to get basic auth from headers"
state <- State.get
logic state
where
logic :: State -> m (Text.Text, Text.Text)
logic state = do
case getHeader state "Authorization" of
Nothing -> Except.throwError Skip
Just header@(_, authValue) -> do
case Char8.words authValue of
["Basic", encodedCreds] -> case Base64.decodeBase64 encodedCreds of
Left _ -> Except.throwError Skip
Right decodedCreds -> case Char8.split ':' decodedCreds of
[userID, password] -> do
IO.liftIO $ print "Basic auth acquired"
State.put $ headerParsed state header
pure $ Bifunctor.bimap Text.decodeUtf8 Text.decodeUtf8 (userID, password)
_ -> Except.throwError Skip
_ -> Except.throwError Skip
-- PARSING BODY
-- TODO: Check HEADERS for correct content type?
-- TODO: Check METHOD for correct HTTP method?
bodyJSON :: forall a m. (MonadOkapi m, Aeson.FromJSON a) => m a
bodyJSON = do
IO.liftIO $ print "Attempting to parse JSON body"
state <- State.get
logic state
where
logic :: State -> m a
logic state
| not $ isMethodParsed state = Except.throwError Skip
| not $ isPathParsed state = Except.throwError Skip
| otherwise =
do
body <- IO.liftIO $ getRequestBody state
case Aeson.decode body of
Nothing -> do
IO.liftIO $ print $ "Couldn't parse " <> show body
Except.throwError Skip
Just value -> do
IO.liftIO $ print "JSON body parsed"
State.put $ bodyParsed state
pure value
bodyForm :: forall a m. (MonadOkapi m, Web.FromForm a) => m a
bodyForm = do
IO.liftIO $ print "Attempting to parse FormURLEncoded body"
state <- State.get
logic state
where
logic :: State -> m a
logic state
| not $ isMethodParsed state = Except.throwError Skip
| not $ isPathParsed state = Except.throwError Skip
| otherwise =
do
body <- IO.liftIO $ getRequestBody state
case eitherToMaybe $ Web.urlDecodeAsForm body of
Nothing -> Except.throwError Skip
Just value -> do
IO.liftIO $ print "FormURLEncoded body parsed"
State.put $ bodyParsed state
pure value
-- TODO: bodyFile functions for file uploads to server
-- RESPONSE FUNCTIONS
respond :: forall m. MonadOkapi m => Natural.Natural -> Headers -> LazyByteString.ByteString -> m Result
respond status headers body = do
IO.liftIO $ print "Attempting to respond from Servo"
state <- State.get
logic state
where
logic :: State -> m Result
logic state
| not $ isMethodParsed state = Except.throwError Skip
| not $ isPathParsed state = Except.throwError Skip
| not $ isQueryParamsParsed state = Except.throwError Skip
-- not $ isBodyParsed request = Except.throwError Skip
| otherwise = do
IO.liftIO $ print "Responded from servo, passing off to WAI"
pure $ ResultResponse $ Response status headers body
-- TODO: Use response builder?
okHTML :: forall m. MonadOkapi m => Headers -> LazyByteString.ByteString -> m Result
okHTML headers = respond 200 ([("Content-Type", "text/html")] <> headers)
okPlainText :: forall m. MonadOkapi m => Headers -> Text.Text -> m Result
okPlainText headers = respond 200 ([("Content-Type", "text/plain")] <> headers) . LazyByteString.fromStrict . Text.encodeUtf8
okJSON :: forall a m. (MonadOkapi m, Aeson.ToJSON a) => Headers -> a -> m Result
okJSON headers = respond 200 ([("Content-Type", "application/json")] <> headers) . Aeson.encode
okLucid :: forall a m. (MonadOkapi m, Lucid.ToHtml a) => Headers -> a -> m Result
okLucid headers = okHTML headers . Lucid.renderBS . Lucid.toHtml
noContent :: forall a m. MonadOkapi m => Headers -> m Result
noContent headers = respond 204 headers ""
redirectTo :: forall a m. MonadOkapi m => Char8.ByteString -> m Result
redirectTo url = respond 302 [("Location", url)] ""
-- File Responses
file :: forall m. MonadOkapi m => Natural.Natural -> Headers -> FilePath -> m Result
file status headers filePath = do
state <- State.get
logic state
where
logic :: State -> m Result
logic state
| not $ isMethodParsed state = Except.throwError Skip
| not $ isPathParsed state = Except.throwError Skip
| not $ isQueryParamsParsed state = Except.throwError Skip
-- not $ isBodyParsed request = Except.throwError Skip
| otherwise = do
IO.liftIO $ print "Responded from servo, passing off to WAI"
pure $ ResultFile $ File status headers filePath
okFile :: forall m. MonadOkapi m => Headers -> FilePath -> m Result
okFile headers = file 200 headers
-- Event Source Responses
connectEventSource :: forall m. MonadOkapi m => EventSource.EventSource -> m Result
connectEventSource eventSource = do
IO.liftIO $ print "Attempting to connect SSE source from Servo"
state <- State.get
logic state
where
logic :: State -> m Result
logic state
| not $ isMethodParsed state = Except.throwError Skip
| not $ isPathParsed state = Except.throwError Skip
| not $ isQueryParamsParsed state = Except.throwError Skip
-- not $ isBodyParsed request = Except.throwError Skip
| otherwise = do
IO.liftIO $ print "Responded from servo, passing off to WAI"
pure $ ResultEventSource eventSource
-- ERROR FUNCTIONS
skip :: forall a m. MonadOkapi m => m a
skip = Except.throwError Skip
error :: forall a m. MonadOkapi m => Natural.Natural -> Headers -> LazyByteString.ByteString -> m a
error status headers = Except.throwError . Error . Response status headers
error500 :: forall a m. MonadOkapi m => Headers -> LazyByteString.ByteString -> m a
error500 = error 500
error401 :: forall a m. MonadOkapi m => Headers -> LazyByteString.ByteString -> m a
error401 = error 401
error403 :: forall a m. MonadOkapi m => Headers -> LazyByteString.ByteString -> m a
error403 = error 403
error404 :: forall a m. MonadOkapi m => Headers -> LazyByteString.ByteString -> m a
error404 = error 404
error422 :: forall a m. MonadOkapi m => Headers -> LazyByteString.ByteString -> m a
error422 = error 422
-- | Execute the next parser even if the first one throws an Error error
(<!>) :: MonadOkapi m => m a -> m a -> m a
parser1 <!> parser2 = Except.catchError parser1 (const parser2)
optionalError :: MonadOkapi m => m a -> m (Maybe a)
optionalError parser = (Just <$> parser) <!> pure Nothing
optionError :: MonadOkapi m => a -> m a -> m a
optionError value parser = do
mbValue <- optionalError parser
case mbValue of
Nothing -> pure value
Just value' -> pure value'
-- PARSING GUARDS AND SWITCHES
isMethodParsed :: State -> Bool
isMethodParsed State {..} = stateRequestMethodParsed
isPathParsed :: State -> Bool
isPathParsed State {..} = Prelude.null $ requestPath stateRequest
isQueryParamsParsed :: State -> Bool
isQueryParamsParsed State {..} = Prelude.null $ requestQuery stateRequest
isBodyParsed :: State -> Bool
isBodyParsed State {..} = stateRequestBodyParsed
methodMatches :: State -> HTTP.Method -> Bool
methodMatches State {..} method = method == requestMethod stateRequest
segMatches :: State -> (Text.Text -> Bool) -> Bool
segMatches state predicate =
maybe False predicate $ getSeg state
getPath :: State -> [Text.Text]
getPath State {..} = requestPath stateRequest
getSeg :: State -> Maybe Text.Text
getSeg State {..} = safeHead (requestPath stateRequest)
getQueryItem :: State -> (Text.Text -> Bool) -> Maybe QueryItem
getQueryItem State {..} predicate = Foldable.find (\(key, _) -> predicate key) (requestQuery stateRequest)
getHeader :: State -> HTTP.HeaderName -> Maybe HTTP.Header
getHeader State {..} key = Foldable.find (\(key', _) -> key == key') (requestHeaders stateRequest)
getRequestBody :: State -> IO LazyByteString.ByteString
getRequestBody State {..} = requestBody stateRequest
methodParsed :: State -> State
methodParsed state = state {stateRequestMethodParsed = True}
segParsed :: State -> State
segParsed state = state {stateRequest = (stateRequest state) {requestPath = Prelude.drop 1 $ requestPath $ stateRequest state}}
pathParsed :: State -> State
pathParsed state = state {stateRequest = (stateRequest state) {requestPath = []}}
queryParamParsed :: State -> QueryItem -> State
queryParamParsed state queryItem = state {stateRequest = (stateRequest state) {requestQuery = List.delete queryItem $ requestQuery $ stateRequest state}}
-- TODO: Don't List.delete header??
headerParsed :: State -> HTTP.Header -> State
headerParsed state header = state {stateRequest = (stateRequest state) {requestHeaders = List.delete header $ requestHeaders $ stateRequest state}}
bodyParsed :: State -> State
bodyParsed state = state {stateRequestBodyParsed = True}
-- HELPERS
eitherToMaybe :: Either l r -> Maybe r
eitherToMaybe (Left _) = Nothing
eitherToMaybe (Right x) = Just x
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x : _) = Just x
lookupBy :: forall a b. (a -> Bool) -> [(a, b)] -> Maybe b
lookupBy _ [] = Nothing
lookupBy predicate ((x, y) : xys)
| predicate x = Just y
| otherwise = lookupBy predicate xys