ihp-1.4.0: IHP/ControllerSupport.hs
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, TypeFamilies, ConstrainedClassMethods, ScopedTypeVariables, FunctionalDependencies, AllowAmbiguousTypes #-}
module IHP.ControllerSupport
( Action'
, (|>)
, getRequestBody
, getRequestPath
, getRequestPathAndQuery
, getHeader
, RequestContext (RequestContext)
, request
, requestHeaders
, getFiles
, Controller (..)
, runAction
, createRequestContext
, ControllerContext
, InitControllerContext (..)
, runActionWithNewContext
, newContextForAction
, respondAndExit
, jumpToAction
, requestBodyJSON
, startWebSocketApp
, startWebSocketAppAndFailOnHTTP
, setHeader
, getAppConfig
) where
import ClassyPrelude
import IHP.HaskellSupport
import Network.Wai (Request, ResponseReceived, responseLBS, requestHeaders)
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai
import IHP.ModelSupport
import IHP.ApplicationContext (ApplicationContext (..))
import Network.Wai.Parse as WaiParse
import qualified Data.ByteString.Lazy
import qualified IHP.Controller.RequestContext as RequestContext
import IHP.Controller.RequestContext (RequestContext, Respond)
import qualified Data.CaseInsensitive
import qualified IHP.ErrorController as ErrorController
import qualified Data.Typeable as Typeable
import IHP.FrameworkConfig (FrameworkConfig (..), ConfigProvider(..))
import qualified IHP.Controller.Context as Context
import IHP.Controller.Context (ControllerContext(ControllerContext), customFieldsRef)
import IHP.Controller.Response
import Network.HTTP.Types.Header
import qualified Data.Aeson as Aeson
import qualified Network.Wai.Handler.WebSockets as WebSockets
import qualified Network.WebSockets as WebSockets
import qualified IHP.WebSocket as WebSockets
import qualified Data.TMap as TypeMap
type Action' = IO ResponseReceived
class (Show controller, Eq controller) => Controller controller where
beforeAction :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?theAction :: controller) => IO ()
beforeAction = pure ()
{-# INLINABLE beforeAction #-}
action :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?theAction :: controller) => controller -> IO ()
class InitControllerContext application where
initContext :: (?modelContext :: ModelContext, ?requestContext :: RequestContext, ?applicationContext :: ApplicationContext, ?context :: ControllerContext) => IO ()
initContext = pure ()
{-# INLINABLE initContext #-}
instance InitControllerContext () where
initContext = pure ()
{-# INLINE runAction #-}
runAction :: forall controller. (Controller controller, ?context :: ControllerContext, ?modelContext :: ModelContext, ?applicationContext :: ApplicationContext, ?requestContext :: RequestContext) => controller -> IO ResponseReceived
runAction controller = do
let ?theAction = controller
let respond = ?context.requestContext.respond
let doRunAction = do
authenticatedModelContext <- prepareRLSIfNeeded ?modelContext
let ?modelContext = authenticatedModelContext
beforeAction
(action controller)
ErrorController.handleNoResponseReturned controller
let handleResponseException (ResponseException response) = respond response
doRunAction `catches` [ Handler handleResponseException, Handler (\exception -> ErrorController.displayException exception controller "")]
{-# INLINE newContextForAction #-}
newContextForAction
:: forall application controller
. ( Controller controller
, ?applicationContext :: ApplicationContext
, ?context :: RequestContext
, InitControllerContext application
, ?application :: application
, Typeable application
, Typeable controller
)
=> controller -> IO (Either (IO ResponseReceived) ControllerContext)
newContextForAction controller = do
let ?modelContext = ?applicationContext.modelContext
let ?requestContext = ?context
controllerContext <- Context.newControllerContext
let ?context = controllerContext
Context.putContext ?application
Context.putContext (Context.ActionType (Typeable.typeOf controller))
try (initContext @application) >>= \case
Left (exception :: SomeException) -> do
pure $ Left $ case fromException exception of
Just (ResponseException response) ->
let respond = ?context.requestContext.respond
in respond response
Nothing -> ErrorController.displayException exception controller " while calling initContext"
Right _ -> pure $ Right ?context
{-# INLINE runActionWithNewContext #-}
runActionWithNewContext :: forall application controller. (Controller controller, ?applicationContext :: ApplicationContext, ?context :: RequestContext, InitControllerContext application, ?application :: application, Typeable application, Typeable controller) => controller -> IO ResponseReceived
runActionWithNewContext controller = do
contextOrResponse <- newContextForAction controller
case contextOrResponse of
Left response -> response
Right context -> do
let ?modelContext = ?applicationContext.modelContext
let ?requestContext = ?context
let ?context = context
runAction controller
-- | If 'IHP.LoginSupport.Helper.Controller.enableRowLevelSecurityIfLoggedIn' was called, this will copy the
-- the prepared RowLevelSecurityContext from the controller context into the ModelContext.
--
-- If row leve security wasn't enabled, this will just return the current model context.
prepareRLSIfNeeded :: (?context :: ControllerContext) => ModelContext -> IO ModelContext
prepareRLSIfNeeded modelContext = do
rowLevelSecurityContext <- Context.maybeFromContext
case rowLevelSecurityContext of
Just context -> pure modelContext { rowLevelSecurity = Just context }
Nothing -> pure modelContext
{-# INLINE startWebSocketApp #-}
startWebSocketApp :: forall webSocketApp application. (?applicationContext :: ApplicationContext, ?context :: RequestContext, InitControllerContext application, ?application :: application, Typeable application, WebSockets.WSApp webSocketApp) => webSocketApp -> IO ResponseReceived -> Network.Wai.Application
startWebSocketApp initialState onHTTP request respond = do
let ?modelContext = ?applicationContext.modelContext
requestContext <- createRequestContext ?applicationContext request respond
let ?requestContext = requestContext
let handleConnection pendingConnection = do
connection <- WebSockets.acceptRequest pendingConnection
controllerContext <- Context.newControllerContext
let ?context = controllerContext
Context.putContext ?application
try (initContext @application) >>= \case
Left (exception :: SomeException) -> putStrLn $ "Unexpected exception in initContext, " <> tshow exception
Right context -> do
WebSockets.startWSApp initialState connection
let connectionOptions = WebSockets.connectionOptions @webSocketApp
request
|> WebSockets.websocketsApp connectionOptions handleConnection
|> \case
Just response -> respond response
Nothing -> onHTTP
{-# INLINE startWebSocketAppAndFailOnHTTP #-}
startWebSocketAppAndFailOnHTTP :: forall webSocketApp application. (?applicationContext :: ApplicationContext, ?context :: RequestContext, InitControllerContext application, ?application :: application, Typeable application, WebSockets.WSApp webSocketApp) => webSocketApp -> Network.Wai.Application
startWebSocketAppAndFailOnHTTP initialState = startWebSocketApp @webSocketApp @application initialState (respond $ responseLBS HTTP.status400 [(hContentType, "text/plain")] "This endpoint is only available via a WebSocket")
where
respond = ?context.respond
jumpToAction :: forall action. (Controller action, ?context :: ControllerContext, ?modelContext :: ModelContext) => action -> IO ()
jumpToAction theAction = do
let ?theAction = theAction
beforeAction @action
action theAction
{-# INLINE getRequestBody #-}
getRequestBody :: (?context :: ControllerContext) => IO LByteString
getRequestBody =
case ?context.requestContext.requestBody of
RequestContext.JSONBody { rawPayload } -> pure rawPayload
_ -> Network.Wai.lazyRequestBody request
-- | Returns the request path, e.g. @/Users@ or @/CreateUser@
getRequestPath :: (?context :: ControllerContext) => ByteString
getRequestPath = request.rawPathInfo
{-# INLINABLE getRequestPath #-}
-- | Returns the request path and the query params, e.g. @/ShowUser?userId=9bd6b37b-2e53-40a4-bb7b-fdba67d6af42@
getRequestPathAndQuery :: (?context :: ControllerContext) => ByteString
getRequestPathAndQuery = request.rawPathInfo <> request.rawQueryString
{-# INLINABLE getRequestPathAndQuery #-}
-- | Returns a header value for a given header name. Returns Nothing if not found
--
-- The header is looked up in a case insensitive way.
--
-- >>> getHeader "Content-Type"
-- Just "text/html"
--
-- >>> getHeader "X-My-Custom-Header"
-- Nothing
--
getHeader :: (?context :: ControllerContext) => ByteString -> Maybe ByteString
getHeader name = lookup (Data.CaseInsensitive.mk name) request.requestHeaders
{-# INLINABLE getHeader #-}
-- | Set a header value for a given header name.
--
-- >>> setHeader ("Content-Language", "en")
--
setHeader :: (?context :: ControllerContext) => Header -> IO ()
setHeader header = do
maybeHeaders <- Context.maybeFromContext @[Header]
let headers = fromMaybe [] maybeHeaders
Context.putContext (header : headers)
{-# INLINABLE setHeader #-}
-- | Returns the current HTTP request.
--
-- See https://hackage.haskell.org/package/wai-3.2.2.1/docs/Network-Wai.html#t:Request
request :: (?context :: ControllerContext) => Network.Wai.Request
request = requestContext.request
{-# INLINE request #-}
{-# INLINE getFiles #-}
getFiles :: (?context :: ControllerContext) => [File Data.ByteString.Lazy.ByteString]
getFiles =
case requestContext.requestBody of
RequestContext.FormBody { files } -> files
_ -> []
requestContext :: (?context :: ControllerContext) => RequestContext
requestContext = ?context.requestContext
{-# INLINE requestContext #-}
requestBodyJSON :: (?context :: ControllerContext) => Aeson.Value
requestBodyJSON =
case ?context.requestContext.requestBody of
RequestContext.JSONBody { jsonPayload = Just value } -> value
_ -> error "Expected JSON body"
{-# INLINE createRequestContext #-}
createRequestContext :: ApplicationContext -> Request -> Respond -> IO RequestContext
createRequestContext ApplicationContext { frameworkConfig } request respond = do
let contentType = lookup hContentType (requestHeaders request)
requestBody <- case contentType of
"application/json" -> do
rawPayload <- Network.Wai.lazyRequestBody request
let jsonPayload = Aeson.decode rawPayload
pure RequestContext.JSONBody { jsonPayload, rawPayload }
_ -> do
(params, files) <- WaiParse.parseRequestBodyEx frameworkConfig.parseRequestBodyOptions WaiParse.lbsBackEnd request
pure RequestContext.FormBody { .. }
pure RequestContext.RequestContext { request, respond, requestBody, frameworkConfig }
-- | Returns a custom config parameter
--
-- >>> getAppConfig @StripePublicKey
-- StripePublicKey "pk_test_..."
--
-- Example:
--
-- First you need to define a custom config parameter in Config.hs:
--
-- > -- Config/Config.hs
-- > newtype StripePublicKey = StripePublicKey Text
-- >
-- > config :: ConfigBuilder
-- > config = do
-- > -- ...
-- > stripePublicKey <- StripePublicKey <$> env @Text "STRIPE_PUBLIC_KEY"
-- > option stripePublicKey
--
-- Then you can access it using 'getAppConfig':
--
-- > action MyAction = do
-- > let (StripePublicKey stripePublicKey) = getAppConfig @StripePublicKey
-- >
-- > putStrLn ("Stripe public key: " <> stripePublicKey)
--
getAppConfig :: forall configParameter context. (?context :: context, ConfigProvider context, Typeable configParameter) => configParameter
getAppConfig = ?context.frameworkConfig.appConfig
|> TypeMap.lookup @configParameter
|> fromMaybe (error ("Could not find " <> (show (Typeable.typeRep (Typeable.Proxy @configParameter))) <>" in config"))
{-# INLINE getAppConfig #-}