Reorg modules again; finalize imports & exports

This commit is contained in:
Rashad Gover 2022-08-06 05:21:53 +00:00
parent 00625b84fc
commit 342ee25ebc
20 changed files with 692 additions and 660 deletions

View File

@ -12,7 +12,7 @@ import GHC.Generics (Generic)
import Okapi
main :: IO ()
main = runOkapi id notFound 3000 calc
main = runOkapi id _404 3000 calc
type Okapi a = OkapiT IO a
@ -27,20 +27,20 @@ addOp = do
pathSeg "add"
(x, y) <- getArgs
respond $
aeson (x + y) ok
json (x + y) _200
subOp :: Okapi Response
subOp = do
pathSeg "sub" <|> pathSeg "minus"
(x, y) <- getArgs
respond $
ok & aeson (x - y)
_200 & json (x - y)
mulOp :: Okapi Response
mulOp = do
pathSeg "mul"
(x, y) <- getArgs
ok & aeson (x * y) & respond
_200 & json (x * y) & respond
data DivResult = DivResult
{ answer :: Int,
@ -53,10 +53,10 @@ divOp = do
pathSeg "div"
(x, y) <- getArgs
if y == 0
then throw forbidden
then throw _403
else
ok
& aeson DivResult {answer = x `div` y, remainder = x `mod` y}
_200
& json DivResult {answer = x `div` y, remainder = x `mod` y}
& respond
getArgs :: Okapi (Int, Int)

View File

@ -12,7 +12,7 @@ import Okapi
import Template
okapiApplication :: Application
okapiApplication = makeOkapiApp id okapiAPI
okapiApplication = okapiApp id okapiAPI
type Okapi a = OkapiT IO a

View File

@ -108,7 +108,7 @@ main :: IO ()
main = do
conn <- open "todo.db"
execute_ conn "CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
runOkapi id notFound 3000 (todoAPI conn)
runOkapi id _404 3000 (todoAPI conn)
close conn
-- SERVER FUNCTIONS
@ -126,7 +126,7 @@ healthCheck :: Okapi Response
healthCheck = do
get
optional $ pathSeg ""
respond ok
respond _200
getTodo :: Connection -> Okapi Response
getTodo conn = do
@ -135,8 +135,8 @@ getTodo conn = do
todoID <- pathParam @Int
maybeTodo <- liftIO $ selectTodo conn todoID
case maybeTodo of
Nothing -> throw internalServerError
Just todo -> ok & aeson todo & respond
Nothing -> throw _500
Just todo -> _200 & json todo & respond
getAllTodos :: Connection -> Okapi Response
getAllTodos conn = do
@ -144,7 +144,7 @@ getAllTodos conn = do
pathSeg "todos"
status <- optional $ queryParam @Status "status"
todos <- liftIO $ selectAllTodos conn status
ok & aeson todos & respond
_200 & json todos & respond
createTodo :: Connection -> Okapi Response
createTodo conn = do
@ -152,7 +152,7 @@ createTodo conn = do
pathSeg "todos"
todoForm <- bodyForm
liftIO $ insertTodoForm conn todoForm
respond ok
respond _200
editTodo :: Connection -> Okapi Response
editTodo conn = do
@ -161,7 +161,7 @@ editTodo conn = do
todoID <- pathParam @Int
todoForm <- bodyForm @TodoForm
liftIO $ updateTodo conn todoID todoForm
respond ok
respond _200
forgetTodo :: Connection -> Okapi Response
forgetTodo conn = do
@ -169,7 +169,7 @@ forgetTodo conn = do
pathSeg "todos"
todoID <- pathParam @Int
liftIO $ deleteTodo conn todoID
respond ok
respond _200
-- DATABASE FUNCTIONS

View File

@ -28,19 +28,13 @@ source-repository head
library
exposed-modules:
Okapi
Okapi.Application
Okapi.Event
Okapi.Failure
Okapi.Internal.Functions.Application
Okapi.Internal.Functions.Event
Okapi.Internal.Functions.Failure
Okapi.Internal.Functions.Parser
Okapi.Internal.Functions.Response
Okapi.Internal.Functions.Route
Okapi.Internal.Types
Okapi.Parser
Okapi.Response
Okapi.Route
Okapi.Test
Okapi.Types
other-modules:
Paths_okapi
hs-source-dirs:

View File

@ -11,16 +11,44 @@
{-# LANGUAGE UndecidableInstances #-}
module Okapi
( module Okapi.Internal.Types,
module Okapi.Event,
module Okapi.Failure,
module Okapi.Parser,
module Okapi.Response,
module Okapi.Route,
module Okapi.Test,
( -- TOP LEVEL
runOkapi,
runOkapiTLS,
runOkapiWebsockets
runOkapiWebsockets,
runOkapiWebsocketsTLS,
-- APPLICATION
module Okapi.Application,
-- SERVER SENT EVENTS
newEventSource,
sendValue,
sendEvent,
-- PARSER
module Okapi.Parser,
-- RESPONSE
module Okapi.Response,
-- ROUTE
module Okapi.Route,
-- TEST
module Okapi.Test,
-- TYPES
MonadOkapi,
OkapiT,
State,
Request,
Response,
ResponseBody,
Failure,
Path,
Headers,
QueryItem,
Query,
Cookie,
Cookies,
ToSSE (..),
Event (..),
Chan,
EventSource,
URL (..),
)
where
@ -60,15 +88,14 @@ import qualified Network.Wai.Handler.WarpTLS as Warp
import qualified Network.Wai.Internal as Wai
import Network.Wai.Middleware.Gzip (def, gzip)
import qualified Network.WebSockets as WS
import Okapi.Application
import Okapi.Event
import qualified Okapi.Event as Event
import Okapi.Failure
import Okapi.Internal.Functions.Application
import Okapi.Internal.Types
import Okapi.Parser
import Okapi.Response
import Okapi.Route
import Okapi.Test
import Okapi.Types
import qualified Web.Cookie as Cookie
import qualified Web.FormUrlEncoded as Web
import qualified Web.HttpApiData as Web
@ -76,14 +103,18 @@ import qualified Web.HttpApiData as Web
runOkapi :: Monad m => (forall a. m a -> IO a) -> Response -> Int -> OkapiT m Response -> IO ()
runOkapi hoister defaultResponse port okapiT = do
print $ "Running Okapi App on port " <> show port
Warp.run port $ makeOkapiApp hoister defaultResponse okapiT
runOkapiWebsockets :: Monad m => (forall a. m a -> IO a) -> Response -> Int -> OkapiT m Response -> WS.ConnectionOptions -> WS.ServerApp -> IO ()
runOkapiWebsockets hoister defaultResponse port okapiT connSettings serverApp = do
print $ "Running Okapi App on port " <> show port
Warp.run port $ makeOkapiAppWebsockets hoister defaultResponse okapiT connSettings serverApp
Warp.run port $ okapiApp hoister defaultResponse okapiT
runOkapiTLS :: Monad m => (forall a. m a -> IO a) -> Response -> Warp.TLSSettings -> Warp.Settings -> OkapiT m Response -> IO ()
runOkapiTLS hoister defaultResponse tlsSettings settings okapiT = do
print "Running servo on port 43"
Warp.runTLS tlsSettings settings $ makeOkapiApp hoister defaultResponse okapiT
Warp.runTLS tlsSettings settings $ okapiApp hoister defaultResponse okapiT
runOkapiWebsockets :: Monad m => (forall a. m a -> IO a) -> Response -> Int -> OkapiT m Response -> WS.ConnectionOptions -> WS.ServerApp -> IO ()
runOkapiWebsockets hoister defaultResponse port okapiT connSettings serverApp = do
print $ "Running Okapi App on port " <> show port
Warp.run port $ okapiAppWebsockets hoister defaultResponse okapiT connSettings serverApp
runOkapiWebsocketsTLS :: Monad m => (forall a. m a -> IO a) -> Response -> Warp.TLSSettings -> Warp.Settings -> OkapiT m Response -> WS.ConnectionOptions -> WS.ServerApp -> IO ()
runOkapiWebsocketsTLS hoister defaultResponse tlsSettings settings okapiT connSettings serverApp = do
Warp.runTLS tlsSettings settings $ okapiAppWebsockets hoister defaultResponse okapiT connSettings serverApp

55
src/Okapi/Application.hs Normal file
View File

@ -0,0 +1,55 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
module Okapi.Application
( okapiApp,
okapiAppWebsockets,
)
where
import qualified Control.Monad.Except as Except
import qualified Control.Monad.Morph as Morph
import qualified Control.Monad.State.Strict as State
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import Network.Wai.Handler.WebSockets
import qualified Network.Wai.Handler.WebSockets as WS
import qualified Network.Wai.Middleware.Gzip as Middleware
import qualified Network.WebSockets as WS
import Okapi.Event
import Okapi.Types
okapiApp :: Monad m => (forall a. m a -> IO a) -> Response -> OkapiT m Response -> Wai.Application
okapiApp hoister defaultResponse okapiT waiRequest respond = do
(eitherFailureOrResponse, _state) <- (State.runStateT . Except.runExceptT . unOkapiT $ Morph.hoist hoister okapiT) (waiRequestToState waiRequest)
let response =
case eitherFailureOrResponse of
Left Skip -> defaultResponse
Left (Error errorResponse) -> errorResponse
Right succesfulResponse -> succesfulResponse
responseToWaiApp response waiRequest respond
where
responseToWaiApp :: Response -> Wai.Application
responseToWaiApp (Response {..}) waiRequest respond = case responseBody of
ResponseBodyRaw body -> respond $ Wai.responseLBS (toEnum $ fromEnum responseStatus) responseHeaders body
ResponseBodyFile filePath -> respond $ Wai.responseFile (toEnum $ fromEnum responseStatus) responseHeaders filePath Nothing
ResponseBodyEventSource eventSource -> (Middleware.gzip Middleware.def $ 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
stateResponded = False
in State {..}
okapiAppWebsockets :: Monad m => (forall a. m a -> IO a) -> Response -> OkapiT m Response -> WS.ConnectionOptions -> WS.ServerApp -> Wai.Application
okapiAppWebsockets hoister defaultResponse okapiT connSettings serverApp =
let backup = okapiApp hoister defaultResponse okapiT
in WS.websocketsOr connSettings serverApp backup

View File

@ -1,6 +1,25 @@
module Okapi.Event where
import Okapi.Internal.Types
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Okapi.Event
( newEventSource,
sendValue,
sendEvent,
eventSourceAppUnagiChan,
)
where
import qualified Control.Concurrent.Chan.Unagi as Unagi
import qualified Control.Monad.IO.Class as IO
import qualified Data.ByteString.Builder as Builder
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Function as Function
import qualified Data.Text.Encoding as Text
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import qualified Network.Wai.EventSource as Wai
import Network.WebSockets (send)
import Okapi.Types
-- TODO: Use MonadOkapi
newEventSource :: IO EventSource
@ -11,3 +30,61 @@ sendValue (inChan, _outChan) = Unagi.writeChan inChan . toSSE
sendEvent :: EventSource -> Event -> IO ()
sendEvent (inChan, _outChan) = Unagi.writeChan inChan
eventSourceAppUnagiChan :: EventSource -> Wai.Application
eventSourceAppUnagiChan (inChan, _outChan) req sendResponse = do
outChan <- IO.liftIO $ Unagi.dupChan inChan
eventSourceAppIO (eventToServerEvent <$> Unagi.readChan outChan) req sendResponse
-- BELOW IS INTERNAL
eventSourceAppIO :: IO Wai.ServerEvent -> Wai.Application
eventSourceAppIO src _ sendResponse =
sendResponse $
Wai.responseStream
HTTP.status200
[(HTTP.hContentType, "text/event-stream")]
$ \sendChunk flush -> do
flush
Function.fix $ \loop -> do
se <- src
case eventToBuilder se of
Nothing -> return ()
Just b -> sendChunk b >> flush >> loop
eventToBuilder :: Wai.ServerEvent -> Maybe Builder.Builder
eventToBuilder (Wai.CommentEvent txt) = Just $ field commentField txt
eventToBuilder (Wai.RetryEvent n) = Just $ field retryField (Builder.string8 . show $ n)
eventToBuilder Wai.CloseEvent = Nothing
eventToBuilder (Wai.ServerEvent n i d) =
Just $
mappend (name n (evid i $ evdata (mconcat d) nl)) nl
where
name Nothing = id
name (Just n') = mappend (field nameField n')
evid Nothing = id
evid (Just i') = mappend (field idField i')
evdata d' = mappend (field dataField d')
nl :: Builder.Builder
nl = Builder.char7 '\n'
nameField, idField, dataField, retryField, commentField :: Builder.Builder
nameField = Builder.string7 "event:"
idField = Builder.string7 "id:"
dataField = Builder.string7 "data:"
retryField = Builder.string7 "retry:"
commentField = Builder.char7 ':'
-- | Wraps the text as a labeled field of an event stream.
field :: Builder.Builder -> Builder.Builder -> Builder.Builder
field l b = l `mappend` b `mappend` nl
eventToServerEvent :: Event -> Wai.ServerEvent
eventToServerEvent Event {..} =
Wai.ServerEvent
(Builder.byteString . Text.encodeUtf8 <$> eventName)
(Builder.byteString . Text.encodeUtf8 <$> eventID)
(Builder.word8 <$> LBS.unpack eventData)
eventToServerEvent (CommentEvent comment) = Wai.CommentEvent $ Builder.lazyByteString comment
eventToServerEvent CloseEvent = Wai.CloseEvent

View File

@ -1,30 +0,0 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RankNTypes #-}
module Okapi.Failure
( module Okapi.Failure,
module Okapi.Internal.Functions.Failure,
)
where
import qualified Control.Monad.Except as Except
import Okapi.Internal.Functions.Failure
import Okapi.Internal.Types
-- | Execute the next parser even if the first one throws an Error failure
(<!>) :: forall a m. MonadOkapi m => m a -> m a -> m a
parser1 <!> parser2 = Except.catchError parser1 (const parser2)
guardE :: forall a m. MonadOkapi m => Response -> Bool -> m ()
guardE _ True = pure ()
guardE response False = throw response
optionalE :: forall a m. MonadOkapi m => m a -> m (Maybe a)
optionalE parser = (Just <$> parser) <!> pure Nothing
optionE :: forall a m. MonadOkapi m => a -> m a -> m a
optionE value parser = do
mbValue <- optionalE parser
case mbValue of
Nothing -> pure value
Just value' -> pure value'

View File

@ -1,51 +0,0 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
module Okapi.Internal.Functions.Application where
import qualified Control.Monad.Except as Except
import qualified Control.Monad.Morph as Morph
import qualified Control.Monad.State.Strict as State
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import Network.Wai.Handler.WebSockets
import qualified Network.Wai.Handler.WebSockets as WS
import qualified Network.Wai.Middleware.Gzip as Middleware
import qualified Network.WebSockets as WS
import Okapi.Internal.Functions.Event
import Okapi.Internal.Types
makeOkapiApp :: Monad m => (forall a. m a -> IO a) -> Response -> OkapiT m Response -> Wai.Application
makeOkapiApp hoister defaultResponse okapiT waiRequest respond = do
(eitherFailureOrResponse, _state) <- (State.runStateT . Except.runExceptT . unOkapiT $ Morph.hoist hoister okapiT) (waiRequestToState waiRequest)
let response =
case eitherFailureOrResponse of
Left Skip -> defaultResponse
Left (Error errorResponse) -> errorResponse
Right succesfulResponse -> succesfulResponse
responseToWaiApp response waiRequest respond
makeOkapiAppWebsockets :: Monad m => (forall a. m a -> IO a) -> Response -> OkapiT m Response -> WS.ConnectionOptions -> WS.ServerApp -> Wai.Application
makeOkapiAppWebsockets hoister defaultResponse okapiT connSettings serverApp =
let backup = makeOkapiApp hoister defaultResponse okapiT
in WS.websocketsOr connSettings serverApp backup
responseToWaiApp :: Response -> Wai.Application
responseToWaiApp (Response {..}) waiRequest respond = case responseBody of
ResponseBodyRaw body -> respond $ Wai.responseLBS (toEnum $ fromEnum responseStatus) responseHeaders body
ResponseBodyFile filePath -> respond $ Wai.responseFile (toEnum $ fromEnum responseStatus) responseHeaders filePath Nothing
ResponseBodyEventSource eventSource -> (Middleware.gzip Middleware.def $ 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
stateResponded = False
in State {..}

View File

@ -1,80 +0,0 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
module Okapi.Internal.Functions.Event where
import qualified Control.Concurrent.Chan.Unagi as Unagi
import qualified Control.Monad.IO.Class as IO
import qualified Data.ByteString.Builder as Builder
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Function as Function
import qualified Data.Map.Strict as Map
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as UUID
import qualified Network.HTTP.Types as HTTP
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.Wai as Wai
import qualified Network.Wai.EventSource as Wai
import Okapi.Internal.Types
-- import Network.Wai.EventSource
-- TODO: Below this point, put in Interface module
eventSourceAppUnagiChan :: EventSource -> Wai.Application
eventSourceAppUnagiChan (inChan, _outChan) req sendResponse = do
outChan <- IO.liftIO $ Unagi.dupChan inChan
eventSourceAppIO (eventToServerEvent <$> Unagi.readChan outChan) req sendResponse
eventSourceAppIO :: IO Wai.ServerEvent -> Wai.Application
eventSourceAppIO src _ sendResponse =
sendResponse $
Wai.responseStream
HTTP.status200
[(HTTP.hContentType, "text/event-stream")]
$ \sendChunk flush -> do
flush
Function.fix $ \loop -> do
se <- src
case eventToBuilder se of
Nothing -> return ()
Just b -> sendChunk b >> flush >> loop
eventToBuilder :: Wai.ServerEvent -> Maybe Builder.Builder
eventToBuilder (Wai.CommentEvent txt) = Just $ field commentField txt
eventToBuilder (Wai.RetryEvent n) = Just $ field retryField (Builder.string8 . show $ n)
eventToBuilder Wai.CloseEvent = Nothing
eventToBuilder (Wai.ServerEvent n i d) =
Just $
mappend (name n (evid i $ evdata (mconcat d) nl)) nl
where
name Nothing = id
name (Just n') = mappend (field nameField n')
evid Nothing = id
evid (Just i') = mappend (field idField i')
evdata d' = mappend (field dataField d')
nl :: Builder.Builder
nl = Builder.char7 '\n'
nameField, idField, dataField, retryField, commentField :: Builder.Builder
nameField = Builder.string7 "event:"
idField = Builder.string7 "id:"
dataField = Builder.string7 "data:"
retryField = Builder.string7 "retry:"
commentField = Builder.char7 ':'
-- | Wraps the text as a labeled field of an event stream.
field :: Builder.Builder -> Builder.Builder -> Builder.Builder
field l b = l `mappend` b `mappend` nl
eventToServerEvent :: Event -> Wai.ServerEvent
eventToServerEvent Event {..} =
Wai.ServerEvent
(Builder.byteString . Text.encodeUtf8 <$> eventName)
(Builder.byteString . Text.encodeUtf8 <$> eventID)
(Builder.word8 <$> LBS.unpack eventData)
eventToServerEvent (CommentEvent comment) = Wai.CommentEvent $ Builder.lazyByteString comment
eventToServerEvent CloseEvent = Wai.CloseEvent

View File

@ -1,15 +0,0 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RankNTypes #-}
module Okapi.Internal.Functions.Failure where
import qualified Control.Monad.Except as Except
import Okapi.Internal.Types
-- ERROR FUNCTIONS
skip :: forall a m. MonadOkapi m => m a
skip = Except.throwError Skip
throw :: forall a m. MonadOkapi m => Response -> m a
throw = Except.throwError . Error

View File

@ -1,133 +0,0 @@
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE UndecidableInstances #-}
module Okapi.Internal.Functions.Parser where
import qualified Control.Applicative as Applicative
import qualified Control.Concurrent.Chan as Chan
import qualified Control.Concurrent.STM.TVar as TVar
import qualified Control.Monad as Monad
import qualified Control.Monad.Except as Except
import Control.Monad.IO.Class (liftIO)
import qualified Control.Monad.IO.Class as IO
import qualified Control.Monad.Morph as Morph
import qualified Control.Monad.Reader.Class as Reader
import qualified Control.Monad.State as State
import qualified Control.Monad.State.Class as State
import qualified Control.Monad.Trans.Except as ExceptT
import qualified Control.Monad.Trans.State.Strict as StateT
import qualified Data.ByteString.Lazy as LazyByteString
import qualified Data.Foldable as Foldable
import qualified Data.List as List
import qualified Data.Text as Text
import qualified Data.Vault.Lazy as Vault
import qualified GHC.Natural as Natural
import qualified Network.HTTP.Types as HTTP
import qualified Okapi.Event as Event
import Okapi.Internal.Functions.Failure
import Okapi.Internal.Types
import Okapi.Response
-- PRIMITIVE PARSERS
parseMethod :: MonadOkapi m => m HTTP.Method
parseMethod = do
isMethodParsed <- methodParsed
if isMethodParsed
then skip
else do
method <- State.gets (requestMethod . stateRequest)
State.modify (\state -> state {stateRequestMethodParsed = True})
pure method
parsePathSeg :: MonadOkapi m => m Text.Text
parsePathSeg = do
maybePathSeg <- State.gets (safeHead . requestPath . stateRequest)
case maybePathSeg of
Nothing -> skip
Just pathSeg -> do
State.modify (\state -> state {stateRequest = (stateRequest state) {requestPath = Prelude.drop 1 $ requestPath $ stateRequest state}})
pure pathSeg
where
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x : _) = Just x
parseQueryItem :: MonadOkapi m => Text.Text -> m QueryItem
parseQueryItem queryItemName = do
maybeQueryItem <- State.gets (Foldable.find (\(queryItemName', _) -> queryItemName == queryItemName') . requestQuery . stateRequest)
case maybeQueryItem of
Nothing -> skip
Just queryItem -> do
State.modify (\state -> state {stateRequest = (stateRequest state) {requestQuery = List.delete queryItem $ requestQuery $ stateRequest state}})
pure queryItem
parseHeader :: MonadOkapi m => HTTP.HeaderName -> m HTTP.Header
parseHeader headerName = do
maybeHeader <- State.gets (Foldable.find (\(headerName', _) -> headerName == headerName') . requestHeaders . stateRequest)
case maybeHeader of
Nothing -> skip
Just header -> do
State.modify (\state -> state {stateRequest = (stateRequest state) {requestHeaders = List.delete header $ requestHeaders $ stateRequest state}})
pure header
parseBody :: forall m. MonadOkapi m => m LazyByteString.ByteString
parseBody = do
isBodyParsed <- bodyParsed
if isBodyParsed
then skip
else do
bodyRef <- State.gets (requestBody . stateRequest)
body <- liftIO bodyRef
State.modify (\state -> state {stateRequestBodyParsed = True})
pure body
-- State Checks
methodParsed :: MonadOkapi m => m Bool
methodParsed = State.gets stateRequestMethodParsed
pathParsed :: MonadOkapi m => m Bool
pathParsed = State.gets (Prelude.null . requestPath . stateRequest)
queryParsed :: MonadOkapi m => m Bool
queryParsed = State.gets (Prelude.null . requestQuery . stateRequest)
headersParsed :: MonadOkapi m => m Bool
headersParsed = State.gets (Prelude.null . requestHeaders . stateRequest)
bodyParsed :: MonadOkapi m => m Bool
bodyParsed = State.gets stateRequestBodyParsed
{-
TODO: HTTPDataStore? Not really needed because you can just pass data normally or store in own monad.
One benefit is that the data is available to all sub-branches without explicitly passing them to every sub-branch.
-- This data structure should be hidden from user
data HTTPDataStore = HTTPDataStore
{ pathStore :: (Map Text Text)
, queryStore :: (Map Text Text)
, headerStore :: (Map Text Text)
}
-- Can only store parsed information
storePathParam :: forall a. FromHttpApiData a => Text -> Okapi a
storePathParam = ...
storeQueryParam :: ... => Text -> Okapi a
storeHeader :: ... => Text -> Okapi a
-- Can fail on Map lookup and data conversion
findPathParam :: forall a. FromHttpApiData a => Okapi a
-}

View File

@ -1,30 +0,0 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
module Okapi.Internal.Functions.Response where
import qualified GHC.Natural as Natural
import qualified Network.HTTP.Types as HTTP
import Okapi.Internal.Types
setResponseStatus :: Natural.Natural -> Response -> Response
setResponseStatus status response = response {responseStatus = status}
setResponseHeaders :: Headers -> Response -> Response
setResponseHeaders headers response = response {responseHeaders = headers}
-- TODO: setResponseCookie
setResponseHeader :: HTTP.Header -> Response -> Response
setResponseHeader header response@Response {..} =
response {responseHeaders = update header responseHeaders}
where
update :: forall a b. Eq a => (a, b) -> [(a, b)] -> [(a, b)]
update pair [] = [pair]
update pair@(key, value) (pair'@(key', value') : ps) =
if key == key'
then pair : ps
else pair' : update pair ps
setResponseBody :: ResponseBody -> Response -> Response
setResponseBody body response = response {responseBody = body}

View File

@ -1,170 +0,0 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Okapi.Internal.Functions.Route where
import Control.Monad.Combinators
import qualified Data.Attoparsec.Text as Atto
import Data.Char
import Data.Maybe
import Data.Text
import Language.Haskell.TH
import Okapi.Internal.Types
import System.Random
parseCurlyExpr :: Atto.Parser CurlyExpr
parseCurlyExpr = between (Atto.char '{') (Atto.char '}') $ do
typeName <- Atto.takeWhile (\char -> isAlphaNum char || char == '[' || char == ']' || char == '(' || char == ')')
transformFunctionNames <- many $ do
Atto.string "->"
Atto.takeWhile isAlphaNum
filterFunctionName <- optional $ do
Atto.char '|'
Atto.takeWhile isAlphaNum
pure $ CurlyExpr typeName transformFunctionNames filterFunctionName
routeParser :: Atto.Parser [RoutePart]
routeParser = many $ do
Atto.skipSpace
parseMethod <|> parsePathSegMatch <|> parseAnonPathSeg <|> parseAnonQueryParam <|> parseBind
parseMethod :: Atto.Parser RoutePart
parseMethod = do
method <- Atto.takeWhile isUpper
case method of
"GET" -> pure $ Method "GET"
"HEAD" -> pure $ Method "HEAD"
"POST" -> pure $ Method "POST"
"DELETE" -> pure $ Method "DELETE"
"PUT" -> pure $ Method "PUT"
"PATCH" -> pure $ Method "PATCH"
_ -> fail "Couldn't parse method"
parsePathSegMatch :: Atto.Parser RoutePart
parsePathSegMatch = do
Atto.char '/'
match <- Atto.takeWhile1 isAlpha
pure $ PathSegMatch match
parseAnonPathSeg :: Atto.Parser RoutePart
parseAnonPathSeg = do
Atto.char '/'
AnonPathSeg <$> parseCurlyExpr
parseAnonQueryParam :: Atto.Parser RoutePart
parseAnonQueryParam = do
Atto.char '?'
queryParamName <- Atto.takeWhile isAlphaNum
AnonQueryParam queryParamName <$> parseCurlyExpr
parseBind :: Atto.Parser RoutePart
parseBind = do
Atto.string ">>="
Atto.skipSpace
functionName <- Atto.takeWhile1 (\char -> isAlphaNum char || char == '.')
pure $ Bind functionName
routePartsToExp :: [RoutePart] -> Q Exp
routePartsToExp [] =
pure $
RecConE
(mkName "Route")
[ (mkName "parser", VarE (mkName "Okapi.skip")),
(mkName "url", LamE [VarP $ mkName "unit"] (AppE (ConE $ mkName "Okapi.URL") (LitE $ StringL "")))
]
routePartsToExp routeParts = do
let binds = Prelude.dropWhile (not . isBind) routeParts
notBinds = Prelude.takeWhile (not . isBind) routeParts
routePartStmtsAndBindings <- mapM routePartStmtAndBinding notBinds
let routePartStmts = Prelude.map (\(_, _, stmts) -> stmts) routePartStmtsAndBindings
bindingsAndTypes = mapMaybe (\(bandTs, _, _) -> bandTs) routePartStmtsAndBindings
bAndTHelper :: (Maybe (Name, Type), Maybe HTTPDataType, Stmt) -> Maybe (Maybe Name, HTTPDataType) = \case
(Nothing, Just pathSegType@(PathSegType seg), _) -> Just (Nothing, pathSegType)
(Just (name, _), Just httpDataType, _) -> Just (Just name, httpDataType)
_ -> Nothing
bindingsAndHTTPDataTypes :: [(Maybe Name, HTTPDataType)] = mapMaybe bAndTHelper routePartStmtsAndBindings
bindings = Prelude.map fst bindingsAndTypes
-- types = map snd bindingsAndTypes
returnStmt :: Stmt =
case bindings of
[] -> NoBindS (AppE (VarE $ mkName "pure") (ConE $ mkName "()"))
[binding] -> NoBindS (AppE (VarE $ mkName "pure") (VarE binding))
_ -> NoBindS (AppE (VarE $ mkName "pure") (TupE (Prelude.map (Just . VarE) bindings)))
leftSide = ParensE (DoE Nothing $ routePartStmts <> [returnStmt])
(middle, rightSide) =
case binds of
[] -> (VarE $ mkName ">>=", LamE [VarP $ mkName "params"] (AppE (VarE $ mkName "pure") (VarE $ mkName "params")))
[Bind functionName] -> (VarE $ mkName ">>=", VarE $ mkName $ unpack functionName)
_ -> (VarE $ mkName ">>", AppE (VarE $ mkName "Okapi.throw") (VarE $ mkName "Okapi.internalServerError"))
pure $
RecConE
(mkName "Okapi.Route")
[ (mkName "parser", UInfixE leftSide middle rightSide),
(mkName "url", LamE [lambdaPattern bindingsAndTypes] (lambdaBody True bindingsAndHTTPDataTypes))
]
isBind :: RoutePart -> Bool
isBind (Bind _) = True
isBind _ = False
-- bindsExp :: NonEmpty RoutePart -> Exp
-- bindsExp (Bind functionName) = VarE $ mkName $ unpack functionName
-- bindsExp ((Bind functionName) :| rps) = UInfixE (VarE $ mkName $ unpack functionName) (VarE $ mkName ">>=") (loop rps)
-- where
-- loop :: [RoutePart] -> Exp
-- loop [] = LamE [WildP] (VarE $ mkName "Okapi.skip")
-- loop ((Bind functionName) : rps) = undefined
lambdaPattern :: [(Name, Type)] -> Pat
lambdaPattern [] = WildP
lambdaPattern [(n, t)] = SigP (VarP n) t
lambdaPattern nAndTs = TupP $ Prelude.map (\(n, t) -> SigP (VarP n) t) nAndTs
isQueryParamType :: HTTPDataType -> Bool
isQueryParamType (AnonQueryParamType _) = True
isQueryParamType _ = False
lambdaBody :: Bool -> [(Maybe Name, HTTPDataType)] -> Exp
lambdaBody _ [] = AppE (ConE (mkName "Okapi.URL")) (LitE $ StringL "")
lambdaBody isFirstQueryParam (combo@(_, httpDataType) : combos) =
UInfixE
(helper isFirstQueryParam combo)
(VarE $ mkName "<>")
( lambdaBody
( not (isQueryParamType httpDataType && isFirstQueryParam) && isFirstQueryParam
)
combos
)
where
helper :: Bool -> (Maybe Name, HTTPDataType) -> Exp
helper _ (Nothing, PathSegType match) = AppE (ConE (mkName "Okapi.URL")) (LitE $ StringL $ "/" <> unpack match)
helper _ (Just name, AnonPathParamType) = AppE (ConE (mkName "Okapi.URL")) (UInfixE (LitE $ StringL "/") (VarE $ mkName "<>") (ParensE $ AppE (VarE $ mkName "toUrlPiece") (VarE name)))
helper isFirstQueryParam' (Just name, AnonQueryParamType queryParamName) = AppE (ConE (mkName "Okapi.URL")) (UInfixE (LitE $ StringL $ unpack $ (if isFirstQueryParam' then "?" else "&") <> queryParamName <> "=") (VarE $ mkName "<>") (ParensE $ AppE (VarE $ mkName "toQueryParam") (VarE name)))
helper _ _ = AppE (ConE (mkName "Okapi.URL")) (LitE $ StringL "")
routePartStmtAndBinding :: RoutePart -> Q (Maybe (Name, Type), Maybe HTTPDataType, Stmt)
routePartStmtAndBinding rp = case rp of
Method m -> case m of
"GET" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.get"))
"HEAD" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.head"))
"POST" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.post"))
"DELETE" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.delete"))
"PUT" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.put"))
"PATCH" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.patch"))
_ -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.skip"))
PathSegMatch txt -> pure (Nothing, Just $ PathSegType txt, NoBindS (AppE (VarE $ mkName "Okapi.pathSeg") (LitE $ StringL $ unpack txt)))
AnonPathSeg (CurlyExpr typeName functionNamesToApply maybeGuardFunction) -> do
stmtBinding <- runIO randName
let stmt = BindS (SigP (VarP stmtBinding) (ConT $ mkName $ unpack typeName)) (VarE (mkName "Okapi.pathParam"))
pure (Just (stmtBinding, ConT $ mkName $ unpack typeName), Just AnonPathParamType, stmt)
AnonQueryParam queryParamName (CurlyExpr typeName functionNamesToApply maybeGuardFunction) -> do
stmtBinding <- runIO randName
let stmt = BindS (SigP (VarP stmtBinding) (ConT $ mkName $ unpack typeName)) (AppE (VarE (mkName "Okapi.queryParam")) (LitE $ StringL $ unpack queryParamName))
pure (Just (stmtBinding, ConT $ mkName $ unpack typeName), Just $ AnonQueryParamType queryParamName, stmt)
Bind functionName -> pure (Nothing, Nothing, NoBindS $ AppE (VarE $ mkName "Okapi.throw") (VarE $ mkName "Okapi.internalServerError"))
randName :: IO Name
randName = do
str <- fmap (Prelude.take 10 . randomRs ('a', 'z')) newStdGen
pure $ mkName str

View File

@ -2,23 +2,72 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
module Okapi.Parser where
module Okapi.Parser
( -- Method parsers
get,
post,
head,
put,
delete,
trace,
connect,
options,
anyMethod,
method,
-- Path parsers
pathSeg,
path,
pathParam,
pathParamRaw,
pathSegWith,
-- Query parsers
queryParam,
queryParamRaw,
queryFlag,
-- Header parsers
basicAuth,
cookies,
header,
-- Body parsers
bodyJSON,
bodyForm,
bodyRaw,
-- Response Helpers
respond,
-- Error Helpers
skip,
throw,
(<!>),
guardThrow,
optionalThrow,
optionThrow,
-- State Checkers
methodParsed,
pathParsed,
queryParsed,
headersParsed,
bodyParsed,
)
where
import Control.Monad.Combinators
import qualified Control.Monad.Except as Except
import qualified Control.Monad.IO.Class as IO
import qualified Control.Monad.State.Strict as State
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Foldable as Foldable
import qualified Data.List as List
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Text.Encoding.Base64
import qualified Network.HTTP.Types as HTTP
import Okapi.Internal.Functions.Failure
import Okapi.Internal.Functions.Parser
import Okapi.Internal.Types
import Okapi.Types
import qualified Web.Cookie as Cookie
import qualified Web.FormUrlEncoded as Web
import qualified Web.HttpApiData as Web
import Prelude hiding (head)
-- METHOD HELPERS
@ -155,9 +204,130 @@ bodyForm = do
bodyRaw :: forall m. MonadOkapi m => m LBS.ByteString
bodyRaw = parseBody
-- Response helpers
respond :: forall m. MonadOkapi m => Response -> m Response
respond response = do
check1 <- methodParsed
check2 <- pathParsed
check3 <- queryParsed
if check1 && check2 && check3 then return response else skip
-- Error HELPERS
skip :: forall a m. MonadOkapi m => m a
skip = Except.throwError Skip
throw :: forall a m. MonadOkapi m => Response -> m a
throw = Except.throwError . Error
(<!>) :: forall a m. MonadOkapi m => m a -> m a -> m a
parser1 <!> parser2 = Except.catchError parser1 (const parser2)
guardThrow :: forall a m. MonadOkapi m => Response -> Bool -> m ()
guardThrow _ True = pure ()
guardThrow response False = throw response
optionalThrow :: forall a m. MonadOkapi m => m a -> m (Maybe a)
optionalThrow parser = (Just <$> parser) <!> pure Nothing
optionThrow :: forall a m. MonadOkapi m => a -> m a -> m a
optionThrow value parser = do
mbValue <- optionalThrow parser
case mbValue of
Nothing -> pure value
Just value' -> pure value'
-- State Checks
methodParsed :: MonadOkapi m => m Bool
methodParsed = State.gets stateRequestMethodParsed
pathParsed :: MonadOkapi m => m Bool
pathParsed = State.gets (Prelude.null . requestPath . stateRequest)
queryParsed :: MonadOkapi m => m Bool
queryParsed = State.gets (Prelude.null . requestQuery . stateRequest)
headersParsed :: MonadOkapi m => m Bool
headersParsed = State.gets (Prelude.null . requestHeaders . stateRequest)
bodyParsed :: MonadOkapi m => m Bool
bodyParsed = State.gets stateRequestBodyParsed
-- PRIMITIVE PARSERS (BELOW IS INTERNAL)
parseMethod :: MonadOkapi m => m HTTP.Method
parseMethod = do
isMethodParsed <- methodParsed
if isMethodParsed
then skip
else do
method <- State.gets (requestMethod . stateRequest)
State.modify (\state -> state {stateRequestMethodParsed = True})
pure method
parsePathSeg :: MonadOkapi m => m Text.Text
parsePathSeg = do
maybePathSeg <- State.gets (safeHead . requestPath . stateRequest)
case maybePathSeg of
Nothing -> skip
Just pathSeg -> do
State.modify (\state -> state {stateRequest = (stateRequest state) {requestPath = Prelude.drop 1 $ requestPath $ stateRequest state}})
pure pathSeg
where
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x : _) = Just x
parseQueryItem :: MonadOkapi m => Text.Text -> m QueryItem
parseQueryItem queryItemName = do
maybeQueryItem <- State.gets (Foldable.find (\(queryItemName', _) -> queryItemName == queryItemName') . requestQuery . stateRequest)
case maybeQueryItem of
Nothing -> skip
Just queryItem -> do
State.modify (\state -> state {stateRequest = (stateRequest state) {requestQuery = List.delete queryItem $ requestQuery $ stateRequest state}})
pure queryItem
parseHeader :: MonadOkapi m => HTTP.HeaderName -> m HTTP.Header
parseHeader headerName = do
maybeHeader <- State.gets (Foldable.find (\(headerName', _) -> headerName == headerName') . requestHeaders . stateRequest)
case maybeHeader of
Nothing -> skip
Just header -> do
State.modify (\state -> state {stateRequest = (stateRequest state) {requestHeaders = List.delete header $ requestHeaders $ stateRequest state}})
pure header
parseBody :: forall m. MonadOkapi m => m LBS.ByteString
parseBody = do
isBodyParsed <- bodyParsed
if isBodyParsed
then skip
else do
bodyRef <- State.gets (requestBody . stateRequest)
body <- IO.liftIO bodyRef
State.modify (\state -> state {stateRequestBodyParsed = True})
pure body
{-
TODO: HTTPDataStore? Not really needed because you can just pass data normally or store in own monad.
One benefit is that the data is available to all sub-branches without explicitly passing them to every sub-branch.
-- This data structure should be hidden from user
data HTTPDataStore = HTTPDataStore
{ pathStore :: (Map Text Text)
, queryStore :: (Map Text Text)
, headerStore :: (Map Text Text)
}
-- Can only store parsed information
storePathParam :: forall a. FromHttpApiData a => Text -> Okapi a
storePathParam = ...
storeQueryParam :: ... => Text -> Okapi a
storeHeader :: ... => Text -> Okapi a
-- Can fail on Map lookup and data conversion
findPathParam :: forall a. FromHttpApiData a => Okapi a
-}

View File

@ -2,70 +2,90 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
module Okapi.Response where
module Okapi.Response
( _200,
_204,
_401,
_403,
_404,
_500,
redirect,
-- RESPONSE BODY MODIFIERS
plaintext,
html,
json,
file,
eventSource,
lucid,
-- RESPONSE SETTERS
setResponseStatus,
setResponseBody,
setResponseHeaders,
setResponseHeader,
)
where
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy as LazyByteString
import qualified Data.ByteString.Lazy as LBS
import Data.Function ((&))
import qualified Data.Text as Text
import Data.Text.Encoding
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 Okapi.Event as Event
import Okapi.Internal.Types
import Okapi.Internal.Functions.Response
import Okapi.Types
-- BASE RESPONSES
ok :: Response
ok =
_200 :: Response
_200 =
let responseStatus = 200
responseHeaders = []
responseBody = ResponseBodyRaw "OK"
in Response {..}
noContent :: Response
noContent =
_204 :: Response
_204 =
let responseStatus = 204
responseHeaders = []
responseBody = ResponseBodyRaw "No Content"
in Response {..}
forbidden :: Response
forbidden =
_403 :: Response
_403 =
let responseStatus = 403
responseHeaders = []
responseBody = ResponseBodyRaw "Forbidden"
in Response {..}
notFound :: Response
notFound =
_404 :: Response
_404 =
let responseStatus = 404
responseHeaders = []
responseBody = ResponseBodyRaw "Not Found"
in Response {..}
unauthorized :: Response
unauthorized =
_401 :: Response
_401 =
let responseStatus = 401
responseHeaders = []
responseBody = ResponseBodyRaw "Unauthorized"
in Response {..}
internalServerError :: Response
internalServerError =
_500 :: Response
_500 =
let responseStatus = 500
responseHeaders = []
responseBody = ResponseBodyRaw "Internal Server Error"
in Response {..}
-- TODO: Change type of URL?
redirectTo :: Char8.ByteString -> Response
redirectTo url =
redirect :: URL -> Response
redirect (URL url) =
let responseStatus = 302
responseHeaders = [("Location", url)]
responseHeaders = [("Location", encodeUtf8 url)]
responseBody = ResponseBodyRaw ""
in Response {..}
@ -75,19 +95,19 @@ plaintext :: Text.Text -> Response -> Response
plaintext text response =
response
& setResponseHeader ("Content-Type", "text/plain")
& setResponseBody (ResponseBodyRaw $ LazyByteString.fromStrict . Text.encodeUtf8 $ text)
& setResponseBody (ResponseBodyRaw $ LBS.fromStrict . Text.encodeUtf8 $ text)
html :: LazyByteString.ByteString -> Response -> Response
html :: LBS.ByteString -> Response -> Response
html htmlRaw response =
response
& setResponseBody (ResponseBodyRaw htmlRaw)
& setResponseHeader ("Content-Type", "text/html")
json :: LazyByteString.ByteString -> Response -> Response
json bytes response =
json :: forall a. Aeson.ToJSON a => a -> Response -> Response
json value response =
response
& setResponseHeader ("Content-Type", "application/json")
& setResponseBody (ResponseBodyRaw bytes)
& setResponseBody (ResponseBodyRaw $ Aeson.encode value)
file :: FilePath -> Response -> Response
file path = setResponseBody (ResponseBodyFile path) -- TODO: setHeader???
@ -97,8 +117,29 @@ eventSource source response =
response
& setResponseBody (ResponseBodyEventSource source)
aeson :: forall a. Aeson.ToJSON a => a -> Response -> Response
aeson = json . Aeson.encode
lucid :: Lucid.Html a -> Response -> Response
lucid = html . Lucid.renderBS
lucid :: forall a. Lucid.ToHtml a => a -> Response -> Response
lucid = html . Lucid.renderBS . Lucid.toHtml
-- RESPONSE SETTERS
setResponseStatus :: Natural.Natural -> Response -> Response
setResponseStatus status response = response {responseStatus = status}
setResponseHeaders :: Headers -> Response -> Response
setResponseHeaders headers response = response {responseHeaders = headers}
-- TODO: setResponseCookie
setResponseHeader :: HTTP.Header -> Response -> Response
setResponseHeader header response@Response {..} =
response {responseHeaders = update header responseHeaders}
where
update :: forall a b. Eq a => (a, b) -> [(a, b)] -> [(a, b)]
update pair [] = [pair]
update pair@(key, value) (pair'@(key', value') : ps) =
if key == key'
then pair : ps
else pair' : update pair ps
setResponseBody :: ResponseBody -> Response -> Response
setResponseBody body response = response {responseBody = body}

View File

@ -8,13 +8,17 @@
--- !!!!!!TODO: Change Name of Okapi to HTTParser??? :TODO!!!!!!
module Okapi.Route where
module Okapi.Route
( Route (..),
route,
)
where
import Control.Applicative
import Control.Applicative.Combinators
import Control.Monad (forM)
import Control.Monad.IO.Class
import Data.Attoparsec.Text
import qualified Data.Attoparsec.Text as Atto
import Data.Char (isAlpha, isAlphaNum, isUpper)
import Data.List.NonEmpty
import Data.Maybe (catMaybes, mapMaybe)
@ -24,9 +28,26 @@ import GHC.ExecutionStack (Location (functionName))
import GHC.Unicode (isAscii)
import Language.Haskell.TH
import Language.Haskell.TH.Quote
import Okapi.Internal.Functions.Route
import Okapi.Types
import System.Random
data Route m i o = Route
{ parser :: OkapiT m o,
url :: i -> URL
}
data RoutePart = Method Text | PathSegMatch Text | AnonPathSeg CurlyExpr | AnonQueryParam Text CurlyExpr | Bind Text
deriving (Eq, Show)
data CurlyExpr
= CurlyExpr
Text -- type name
[Text] -- transform function names
(Maybe Text) -- filter function name
deriving (Eq, Show)
data HTTPDataType = PathSegType Text | AnonPathParamType | AnonQueryParamType Text
route :: QuasiQuoter
route =
QuasiQuoter
@ -38,11 +59,167 @@ route =
where
genRouteExp :: Text -> Q Exp
genRouteExp txt = do
let parserResult = parseOnly routeParser txt
let parserResult = Atto.parseOnly routeParser txt
case parserResult of
Left _ -> routePartsToExp []
Right routeParts -> routePartsToExp routeParts
parseCurlyExpr :: Atto.Parser CurlyExpr
parseCurlyExpr = between (Atto.char '{') (Atto.char '}') $ do
typeName <- Atto.takeWhile (\char -> isAlphaNum char || char == '[' || char == ']' || char == '(' || char == ')')
transformFunctionNames <- many $ do
Atto.string "->"
Atto.takeWhile isAlphaNum
filterFunctionName <- optional $ do
Atto.char '|'
Atto.takeWhile isAlphaNum
pure $ CurlyExpr typeName transformFunctionNames filterFunctionName
routeParser :: Atto.Parser [RoutePart]
routeParser = many $ do
Atto.skipSpace
parseMethod <|> parsePathSegMatch <|> parseAnonPathSeg <|> parseAnonQueryParam <|> parseBind
parseMethod :: Atto.Parser RoutePart
parseMethod = do
method <- Atto.takeWhile isUpper
case method of
"GET" -> pure $ Method "GET"
"HEAD" -> pure $ Method "HEAD"
"POST" -> pure $ Method "POST"
"DELETE" -> pure $ Method "DELETE"
"PUT" -> pure $ Method "PUT"
"PATCH" -> pure $ Method "PATCH"
_ -> fail "Couldn't parse method"
parsePathSegMatch :: Atto.Parser RoutePart
parsePathSegMatch = do
Atto.char '/'
match <- Atto.takeWhile1 isAlpha
pure $ PathSegMatch match
parseAnonPathSeg :: Atto.Parser RoutePart
parseAnonPathSeg = do
Atto.char '/'
AnonPathSeg <$> parseCurlyExpr
parseAnonQueryParam :: Atto.Parser RoutePart
parseAnonQueryParam = do
Atto.char '?'
queryParamName <- Atto.takeWhile isAlphaNum
AnonQueryParam queryParamName <$> parseCurlyExpr
parseBind :: Atto.Parser RoutePart
parseBind = do
Atto.string ">>="
Atto.skipSpace
functionName <- Atto.takeWhile1 (\char -> isAlphaNum char || char == '.')
pure $ Bind functionName
routePartsToExp :: [RoutePart] -> Q Exp
routePartsToExp [] =
pure $
RecConE
(mkName "Route")
[ (mkName "parser", VarE (mkName "Okapi.skip")),
(mkName "url", LamE [VarP $ mkName "unit"] (AppE (ConE $ mkName "Okapi.URL") (LitE $ StringL "")))
]
routePartsToExp routeParts = do
let binds = Prelude.dropWhile (not . isBind) routeParts
notBinds = Prelude.takeWhile (not . isBind) routeParts
routePartStmtsAndBindings <- mapM routePartStmtAndBinding notBinds
let routePartStmts = Prelude.map (\(_, _, stmts) -> stmts) routePartStmtsAndBindings
bindingsAndTypes = mapMaybe (\(bandTs, _, _) -> bandTs) routePartStmtsAndBindings
bAndTHelper :: (Maybe (Name, Type), Maybe HTTPDataType, Stmt) -> Maybe (Maybe Name, HTTPDataType) = \case
(Nothing, Just pathSegType@(PathSegType seg), _) -> Just (Nothing, pathSegType)
(Just (name, _), Just httpDataType, _) -> Just (Just name, httpDataType)
_ -> Nothing
bindingsAndHTTPDataTypes :: [(Maybe Name, HTTPDataType)] = mapMaybe bAndTHelper routePartStmtsAndBindings
bindings = Prelude.map fst bindingsAndTypes
-- types = map snd bindingsAndTypes
returnStmt :: Stmt =
case bindings of
[] -> NoBindS (AppE (VarE $ mkName "pure") (ConE $ mkName "()"))
[binding] -> NoBindS (AppE (VarE $ mkName "pure") (VarE binding))
_ -> NoBindS (AppE (VarE $ mkName "pure") (TupE (Prelude.map (Just . VarE) bindings)))
leftSide = ParensE (DoE Nothing $ routePartStmts <> [returnStmt])
(middle, rightSide) =
case binds of
[] -> (VarE $ mkName ">>=", LamE [VarP $ mkName "params"] (AppE (VarE $ mkName "pure") (VarE $ mkName "params")))
[Bind functionName] -> (VarE $ mkName ">>=", VarE $ mkName $ unpack functionName)
_ -> (VarE $ mkName ">>", AppE (VarE $ mkName "Okapi.throw") (VarE $ mkName "Okapi.internalServerError"))
pure $
RecConE
(mkName "Okapi.Route")
[ (mkName "parser", UInfixE leftSide middle rightSide),
(mkName "url", LamE [lambdaPattern bindingsAndTypes] (lambdaBody True bindingsAndHTTPDataTypes))
]
isBind :: RoutePart -> Bool
isBind (Bind _) = True
isBind _ = False
-- bindsExp :: NonEmpty RoutePart -> Exp
-- bindsExp (Bind functionName) = VarE $ mkName $ unpack functionName
-- bindsExp ((Bind functionName) :| rps) = UInfixE (VarE $ mkName $ unpack functionName) (VarE $ mkName ">>=") (loop rps)
-- where
-- loop :: [RoutePart] -> Exp
-- loop [] = LamE [WildP] (VarE $ mkName "Okapi.skip")
-- loop ((Bind functionName) : rps) = undefined
lambdaPattern :: [(Name, Type)] -> Pat
lambdaPattern [] = WildP
lambdaPattern [(n, t)] = SigP (VarP n) t
lambdaPattern nAndTs = TupP $ Prelude.map (\(n, t) -> SigP (VarP n) t) nAndTs
isQueryParamType :: HTTPDataType -> Bool
isQueryParamType (AnonQueryParamType _) = True
isQueryParamType _ = False
lambdaBody :: Bool -> [(Maybe Name, HTTPDataType)] -> Exp
lambdaBody _ [] = AppE (ConE (mkName "Okapi.URL")) (LitE $ StringL "")
lambdaBody isFirstQueryParam (combo@(_, httpDataType) : combos) =
UInfixE
(helper isFirstQueryParam combo)
(VarE $ mkName "<>")
( lambdaBody
( not (isQueryParamType httpDataType && isFirstQueryParam) && isFirstQueryParam
)
combos
)
where
helper :: Bool -> (Maybe Name, HTTPDataType) -> Exp
helper _ (Nothing, PathSegType match) = AppE (ConE (mkName "Okapi.URL")) (LitE $ StringL $ "/" <> unpack match)
helper _ (Just name, AnonPathParamType) = AppE (ConE (mkName "Okapi.URL")) (UInfixE (LitE $ StringL "/") (VarE $ mkName "<>") (ParensE $ AppE (VarE $ mkName "toUrlPiece") (VarE name)))
helper isFirstQueryParam' (Just name, AnonQueryParamType queryParamName) = AppE (ConE (mkName "Okapi.URL")) (UInfixE (LitE $ StringL $ unpack $ (if isFirstQueryParam' then "?" else "&") <> queryParamName <> "=") (VarE $ mkName "<>") (ParensE $ AppE (VarE $ mkName "toQueryParam") (VarE name)))
helper _ _ = AppE (ConE (mkName "Okapi.URL")) (LitE $ StringL "")
routePartStmtAndBinding :: RoutePart -> Q (Maybe (Name, Type), Maybe HTTPDataType, Stmt)
routePartStmtAndBinding rp = case rp of
Method m -> case m of
"GET" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.get"))
"HEAD" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.head"))
"POST" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.post"))
"DELETE" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.delete"))
"PUT" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.put"))
"PATCH" -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.patch"))
_ -> pure (Nothing, Nothing, NoBindS (VarE $ mkName "Okapi.skip"))
PathSegMatch txt -> pure (Nothing, Just $ PathSegType txt, NoBindS (AppE (VarE $ mkName "Okapi.pathSeg") (LitE $ StringL $ unpack txt)))
AnonPathSeg (CurlyExpr typeName functionNamesToApply maybeGuardFunction) -> do
stmtBinding <- runIO randName
let stmt = BindS (SigP (VarP stmtBinding) (ConT $ mkName $ unpack typeName)) (VarE (mkName "Okapi.pathParam"))
pure (Just (stmtBinding, ConT $ mkName $ unpack typeName), Just AnonPathParamType, stmt)
AnonQueryParam queryParamName (CurlyExpr typeName functionNamesToApply maybeGuardFunction) -> do
stmtBinding <- runIO randName
let stmt = BindS (SigP (VarP stmtBinding) (ConT $ mkName $ unpack typeName)) (AppE (VarE (mkName "Okapi.queryParam")) (LitE $ StringL $ unpack queryParamName))
pure (Just (stmtBinding, ConT $ mkName $ unpack typeName), Just $ AnonQueryParamType queryParamName, stmt)
Bind functionName -> pure (Nothing, Nothing, NoBindS $ AppE (VarE $ mkName "Okapi.throw") (VarE $ mkName "Okapi.internalServerError"))
randName :: IO Name
randName = do
str <- fmap (Prelude.take 10 . randomRs ('a', 'z')) newStdGen
pure $ mkName str
{-
newtype URL = URL Text

View File

@ -1,7 +1,19 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
module Okapi.Test where
module Okapi.Test
( TestRequest (..),
testParser,
testParserIO,
assertFailure,
assertState,
assertResponse,
-- For use with Wai.Test
runSession,
withSession,
testRequest,
)
where
import qualified Control.Monad.Except as Except
import Control.Monad.IO.Class (liftIO)
@ -17,9 +29,16 @@ import Network.Wai (defaultRequest)
import qualified Network.Wai as Wai
import Network.Wai.Test (SRequest (..), setRawPathInfo)
import qualified Network.Wai.Test as Wai.Test
import Okapi.Internal.Functions.Application
import Okapi.Internal.Types
import Okapi.Application
import Okapi.Response
import Okapi.Types
data TestRequest = TestRequest
{ testRequestMethod :: HTTP.Method,
testRequestHeaders :: HTTP.RequestHeaders,
testRequestRawPath :: BS.ByteString,
testRequestBody :: LBS.ByteString
}
testParser ::
Monad m =>
@ -50,7 +69,7 @@ testRequestToState (TestRequest method headers rawPath body) =
stateResponded = False
in State {..}
-- ASSERTION FUNCTIONS
-- ASSERTION FUNCTIONS TODO: Add common assertion helpers
assertFailure ::
(Failure -> Bool) ->
@ -83,7 +102,7 @@ runSession ::
OkapiT m Response ->
IO a
runSession session hoister okapiT = do
let app = makeOkapiApp hoister notFound okapiT
let app = okapiApp hoister _404 okapiT
Wai.Test.runSession session app
withSession ::
@ -94,8 +113,8 @@ withSession ::
IO a
withSession hoister okapiT session = runSession session hoister okapiT
send :: TestRequest -> Wai.Test.Session Wai.Test.SResponse
send TestRequest {..} =
testRequest :: TestRequest -> Wai.Test.Session Wai.Test.SResponse
testRequest TestRequest {..} =
let request =
Wai.defaultRequest
{ Wai.requestMethod = testRequestMethod,

View File

@ -6,9 +6,10 @@
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE UndecidableInstances #-}
module Okapi.Internal.Types where
module Okapi.Types where
import qualified Control.Applicative as Applicative
import qualified Control.Concurrent.Chan.Unagi as Unagi
@ -202,29 +203,5 @@ type Chan a = (Unagi.InChan a, Unagi.OutChan a)
type EventSource = Chan Event
data Route m i o = Route
{ parser :: OkapiT m o,
url :: i -> URL
}
newtype URL = URL {unURL :: Text}
deriving newtype (IsString, Semigroup, Monoid, Eq, Ord, Show)
data RoutePart = Method Text | PathSegMatch Text | AnonPathSeg CurlyExpr | AnonQueryParam Text CurlyExpr | Bind Text
deriving (Eq, Show)
data CurlyExpr
= CurlyExpr
Text -- type name
[Text] -- transform function names
(Maybe Text) -- filter function name
deriving (Eq, Show)
data HTTPDataType = PathSegType Text | AnonPathParamType | AnonQueryParamType Text
data TestRequest = TestRequest
{ testRequestMethod :: HTTP.Method,
testRequestHeaders :: HTTP.RequestHeaders,
testRequestRawPath :: BS.ByteString,
testRequestBody :: LBS.ByteString
}

View File

@ -16,18 +16,17 @@ import Network.Wai
import Network.Wai.EventSource (ServerEvent (RetryEvent))
import Network.Wai.Test
import Okapi
import qualified Okapi
import Web.HttpApiData
import Test.DocTest (mainFromCabal)
import System.Environment (getArgs)
import Test.DocTest (mainFromCabal)
import Web.HttpApiData
runDoctests :: IO ()
runDoctests = mainFromCabal "okapi" =<< getArgs
type Okapi = OkapiT IO
someRoute = [route|
someRoute =
[route|
GET
HEAD
/movies
@ -39,7 +38,7 @@ someRoute = [route|
|]
someRouteHandler :: (Int, Text, Text, Text) -> Okapi Okapi.Response
someRouteHandler (_, _, _, _) = respond ok
someRouteHandler (_, _, _, _) = respond _200
someRoute2 =
[route|
@ -54,7 +53,7 @@ someRoute2 =
|]
someRoute2Handler :: (Int, Text, Text, Text) -> Okapi Okapi.Response
someRoute2Handler (_, _, _, _) = respond ok
someRoute2Handler (_, _, _, _) = respond _200
someRoute3 =
[route|
@ -65,17 +64,17 @@ someRoute3 =
|]
verifyTodo :: Int -> Okapi Okapi.Response
verifyTodo id_ = if id_ > 0 then pure ok else Okapi.throw noContent
verifyTodo id_ = if id_ > 0 then pure _200 else Okapi.throw _204
someRoute3TestSession :: Session ()
someRoute3TestSession = do
send (TestRequest methodGet [] "/todos/20" "")
testRequest (TestRequest methodGet [] "/todos/20" "")
>>= assertStatus 200
send (TestRequest methodGet [] "/todos" "")
testRequest (TestRequest methodGet [] "/todos" "")
>>= assertStatus 404
send (TestRequest methodGet [] "/todos/-1" "")
testRequest (TestRequest methodGet [] "/todos/-1" "")
>>= assertStatus 204
testSomeRoute3 :: IO ()
@ -93,36 +92,36 @@ testServer = do
let parser1 = do
get
pathSeg "todos"
respond ok
respond _200
parser2 = do
get
path ["todos", "completed"]
respond ok
respond _200
parser3 = do
get
pathSeg "todos"
status <- queryParam "status"
ok
_200
& plaintext status
& respond
parser4 = do
get
pathSeg "a"
respond ok
respond _200
parser5 = do
get
pathSeg "todos"
queryFlag "progress"
respond ok
respond _200
parser6 = do
get
pathSeg ""
respond ok
respond _200
choice
[ parser1,
@ -134,49 +133,50 @@ testServer = do
]
testServerQuasi :: Okapi Okapi.Response
testServerQuasi = choice
[ parser1
, parser2
, parser3
, parser4
]
where
parser1 = parser [route|GET /todos|] >> respond ok
parser2 = parser [route|GET /todos /completed|] >> respond ok
parser3 = parser [route|GET /todos ?status{Text}|] >>= (\status -> ok & plaintext status & respond)
parser4 = parser [route|GET /a|] >> respond ok
testServerQuasi =
choice
[ parser1,
parser2,
parser3,
parser4
]
where
parser1 = parser [route|GET /todos|] >> respond _200
parser2 = parser [route|GET /todos /completed|] >> respond _200
parser3 = parser [route|GET /todos ?status{Text}|] >>= (\status -> _200 & plaintext status & respond)
parser4 = parser [route|GET /a|] >> respond _200
testSession :: Session ()
testSession = do
send (TestRequest methodGet [] "/todos" "")
testRequest (TestRequest methodGet [] "/todos" "")
>>= assertStatus 200
send (TestRequest methodPost [] "/todos" "")
testRequest (TestRequest methodPost [] "/todos" "")
>>= assertStatus 404
send (TestRequest methodGet [] "/todos/completed" "")
testRequest (TestRequest methodGet [] "/todos/completed" "")
>>= assertStatus 200
res3 <- send $ TestRequest methodGet [] "/todos?status=done" ""
res3 <- testRequest $ TestRequest methodGet [] "/todos?status=done" ""
assertStatus 200 res3
assertBody "done" res3
-- send (TestRequest methodGet [] "/todos?progress" "")
-- testRequest (TestRequest methodGet [] "/todos?progress" "")
-- >>= assertStatus 200
send (TestRequest methodGet [] "/todos?what" "")
testRequest (TestRequest methodGet [] "/todos?what" "")
>>= assertStatus 404
send (TestRequest methodGet [] "/what" "")
testRequest (TestRequest methodGet [] "/what" "")
>>= assertStatus 404
send (TestRequest methodGet [] "/a" "")
testRequest (TestRequest methodGet [] "/a" "")
>>= assertStatus 200
-- testSession2 = do
-- send (TestRequest methodGet [] "/")
-- testRequest (TestRequest methodGet [] "/")
-- send (TestRequest methodGet [] "/" "") ?? Maybe because of how path is stored in srequest
-- testRequest (TestRequest methodGet [] "/" "") ?? Maybe because of how path is stored in srequest
-- >>= assertStatus 200
{-
test1 :: IO ()