diff --git a/examples/Simple.hs b/examples/Simple.hs deleted file mode 100644 index 15bc9fefb7ccf3d01c7bc6a4dab9feab5cd8e37e..0000000000000000000000000000000000000000 --- a/examples/Simple.hs +++ /dev/null @@ -1,224 +0,0 @@ -{-| -Module : Simple -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -Demonstration of a simple stateful web service built using Hikaru. - -Simple /= Easy /= Short. Happy reading. --} - -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE RecordWildCards #-} -{-# LANGUAGE ApplicativeDo #-} - -module Simple (main) -where - import Prelude - import Control.Concurrent.MVar - import Control.Monad.Reader - import Data.Maybe - import Data.Text (Text) - import Hikaru - import Lucid - import Network.HTTP.Types.Header - import Network.HTTP.Types.Status - import Network.Wai - import Network.Wai.Handler.Warp - import Network.Wai.Middleware.RequestLogger - - - -- Action ------------------------------------------------------------------ - - - -- | - -- Our custom action monad allows us to inspect request, - -- build response and consult the model at the same time. - -- - newtype Action a - = Action - { unAction :: ReaderT DemoEnv IO a - } - deriving (Functor, Applicative, Monad, MonadIO) - - instance MonadAction Action where - getActionEnv = Action (demoActionEnv <$> ask) - - instance MonadModel Action where - getModelEnv = Action (demoModelEnv <$> ask) - - - data DemoEnv - = DemoEnv - { demoActionEnv :: ActionEnv - , demoModelEnv :: ModelEnv - } - - - -- Model ------------------------------------------------------------------- - - - class (MonadIO m) => MonadModel m where - getModelEnv :: m ModelEnv - - - data ModelEnv - = ModelEnv - { modelCounter :: MVar Word - } - - - makeModelEnv :: Word -> IO ModelEnv - makeModelEnv n = ModelEnv <$> newMVar n - - - countVisitor :: (MonadModel m) => m Word - countVisitor = do - counter <- modelCounter <$> getModelEnv - liftIO $ do - modifyMVar_ counter (return . succ) - readMVar counter - - - -- Dispatching ------------------------------------------------------------- - - - runAction :: ModelEnv -> Action () -> Application - runAction me act = do - respond $ \ae -> do - runReaderT (unAction act) (DemoEnv ae me) - - - makeApplication :: ModelEnv -> Application - makeApplication me = do - dispatch (runAction me) $ do - -- Register nicer error handlers. - handler NotFound handleNotFound - - -- Plug in a cool logging middleware. - middleware $ logStdoutDev - - -- Negotiate content for the root page. - route $ getRootHtmlR <$ get <* offerHTML - route $ getRootTextR <$ get <* offerText - - -- Disable caching for all the following endpoints. - wrapAction (defaultHeader hCacheControl "no-store" >>) $ do - -- Return search results and repeat the form. - route $ getSearchHtmlR <$ get </ "search" - <* offerHTML - - -- Present a simple greeting page. - route $ getHelloR <$ get </ "hello" <*> arg - <* offerText - - -- Create an echoing JSON API. - route $ postEchoR <$ post </ "api" </ "echo" - <* offerJSON <* acceptJSON - - - -- Handlers ---------------------------------------------------------------- - - - getRootHtmlR :: Action () - getRootHtmlR = do - -- Update the counter. - n <- countVisitor - - -- Present fancy HTML result. - sendHTML $ do - h1_ "Welcome!" - p_ $ "You are " >> toHtml (show n) >> ". visitor!" - - form_ [action_ "/search", method_ "GET"] $ do - view <- newForm "search" $ searchForm (Just "meaning of life") - formView_ view - - - getSearchHtmlR :: Action () - getSearchHtmlR = do - sendHTML $ do - (maybeQuery, view) <- getForm "search" (searchForm Nothing) - - h1_ "Search results" - form_ [method_ "GET"] $ do - formView_ view - - case maybeQuery of - Nothing -> "" - Just q -> do - hr_ [] - h2_ $ toHtml q - p_ "Sorry, no results found!" - - - searchForm :: (Monad m) => Maybe Text -> FormT Text m (Maybe Text) - searchForm q = do - q' <- inputField "q" "Query" q - _ <- button "search" "Search" - return q' - - - formView_ :: (MonadAction m, Localized l) => FormView l -> HtmlT m () - formView_ view = do - forM_ (formElements view) $ \element -> - case element of - Button{..} -> do - button_ [ id_ elemName - , name_ elemName - ] $ lc_ elemLabel - - InputField{..} -> do - label_ [ for_ elemName ] $ do - lc_ elemLabel - ":" - - input_ [ id_ elemName - , name_ elemName - , value_ (fromMaybe "" elemValue) - ] - - - getRootTextR :: Action () - getRootTextR = do - -- Update the counter. - n <- countVisitor - - -- Present a plain textual result. - sendString $ unlines [ "Welcome!" - , "You are " <> show n <> ". visitor!" - ] - - - postEchoR :: Action () - postEchoR = sendJSON =<< getJSON - - - getHelloR :: Text -> Action () - getHelloR name = sendText $ "Hello, " <> name <> "!" - - - handleNotFound :: RequestError -> Text -> Action () - handleNotFound _exn msg = do - setStatus status404 - sendHTML $ do - h1_ "404 Not Found" - p_ (toHtml msg) - - - -- Serving ----------------------------------------------------------------- - - - main :: IO () - main = do - putStrLn "Listening (port 5000) ..." - model <- makeModelEnv 0 - run 5000 (makeApplication model) - - --- vim:set ft=haskell sw=2 ts=2 et: diff --git a/hikaru.cabal b/hikaru.cabal index f7e3958db8f5e3587321bcebd80aec16e8b54426..aeeb1dcfbea9738ba74bb906e2ccf38e25a1c31e 100644 --- a/hikaru.cabal +++ b/hikaru.cabal @@ -1,7 +1,7 @@ cabal-version: 3.0 name: hikaru -version: 0.1.0.0 +version: 0.2.0.0 homepage: https://github.com/mordae/hikaru#readme bug-reports: https://github.com/mordae/hikaru/issues @@ -33,7 +33,9 @@ common common default-extensions: AllowAmbiguousTypes + BangPatterns BlockArguments + ConstraintKinds DataKinds DefaultSignatures DeriveGeneric @@ -41,7 +43,6 @@ common common EmptyDataDecls FlexibleContexts FlexibleInstances - GADTs GeneralizedNewtypeDeriving ImportQualifiedPost KindSignatures @@ -50,12 +51,15 @@ common common NamedFieldPuns NoImplicitPrelude OverloadedStrings + PolyKinds RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving TupleSections TypeApplications + TypeFamilies + TypeOperators UndecidableInstances ghc-options: @@ -68,7 +72,7 @@ common common build-depends: , aeson >=1.4 && <1.6 - , base >=4.13 && <4.16 + , base >=4.13 && <5 , binary >=0.8 && <0.9 , bytestring >=0.10 && <0.12 , case-insensitive >=1.2 && <1.3 @@ -77,6 +81,7 @@ common common , cryptonite >=0.26 && <0.29 , foreign-store >=0.2 && <0.3 , http-types >=0.12 && <0.13 + , hvect >=0.4 && <0.5 , lucid >=2.9 && <2.10 , memory >=0.15 && <0.16 , mtl >=2.2 && <2.3 diff --git a/lib/Hikaru.hs b/lib/Hikaru.hs index cb29de1990f8c69627812bf9e56cd1dd2b907aa2..4199def6e11616c403116f2741646871e12596d0 100644 --- a/lib/Hikaru.hs +++ b/lib/Hikaru.hs @@ -1,14 +1,14 @@ -{-| -Module : Hikaru -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module re-exports other modules in the package. --} +-- | +-- Module : Hikaru +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module re-exports other modules in the package. +-- module Hikaru ( diff --git a/lib/Hikaru/Action.hs b/lib/Hikaru/Action.hs index e9670a4b89eee8cee1ef10cc31de99c3287ebf29..d7242b908b423e22491c71e839afb49e317fb437 100644 --- a/lib/Hikaru/Action.hs +++ b/lib/Hikaru/Action.hs @@ -1,15 +1,15 @@ -{-| -Module : Hikaru.Action -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides a monad for reacting to user requests by -building responses. --} +-- | +-- Module : Hikaru.Action +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides a monad for reacting to user requests by +-- building responses. +-- module Hikaru.Action ( MonadAction(..) @@ -87,7 +87,8 @@ module Hikaru.Action , wsReceive -- ** Errors - , throwError + , abort + , abortMiddleware -- ** Localization , getLanguages @@ -169,6 +170,24 @@ where instance (MonadAction m) => MonadAction (HtmlT m) + -- | + -- Exception raised by 'abort'. + -- + data AbortAction + = AbortAction + { status :: Status + , headers :: [Header] + , message :: Text + } + deriving (Show, Generic) + + instance NFData AbortAction where + rnf AbortAction{status = Status{..}, ..} = + rnf (statusCode, statusMessage, headers, message) + + instance Exception AbortAction + + -- | -- Obtain only the specific 'ActionEnv' field value. -- @@ -522,8 +541,8 @@ where -- -- Returns 'Data.ByteString.empty' once the whole body has been consumed. -- - -- * Throws 'PayloadTooLarge' if reading next chunk would exceed - -- the allotted request body limit. See 'setBodyLimit' for more. + -- * Aborts with 'requestEntityTooLarge413' if reading next chunk would + -- exceed the allotted request body limit. See 'setBodyLimit' for more. -- getBodyChunk :: (MonadAction m) => m ByteString getBodyChunk = do @@ -536,8 +555,8 @@ where -- -- Returns 'Data.ByteString.empty' once the whole body has been consumed. -- - -- * Throws 'PayloadTooLarge' if reading next chunk would exceed - -- the allotted request body limit. See 'setBodyLimit' for more. + -- * Aborts with 'requestEntityTooLarge413' if reading next chunk would + -- exceed the allotted request body limit. See 'setBodyLimit' for more. -- getBodyChunkIO :: (MonadAction m) => m (IO ByteString) getBodyChunkIO = do @@ -555,12 +574,8 @@ where return chunk else do - throwLimitIO limit - - where - throwLimitIO :: Int64 -> IO a - throwLimitIO n = throwIO (PayloadTooLarge, msg :: Text) - where msg = "Limit is " <> tshow n <> " bytes." + abort requestEntityTooLarge413 [] + ("Limit is " <> tshow limit <> " bytes.") -- | @@ -574,7 +589,8 @@ where -- Using this function directly will prevent access to the body in other -- ways, such as through the 'getJSON', 'getFields' or 'getFiles'. -- - -- * Reading the body can throw 'PayloadTooLarge'. + -- * Aborts with 'requestEntityTooLarge413' if reading next chunk would + -- exceed the allotted request body limit. See 'setBodyLimit' for more. -- getBodyRaw :: (MonadAction m) => m LBS.ByteString getBodyRaw = do @@ -595,16 +611,16 @@ where -- | -- Read, parse, cache and return 'Value' sent by the user. -- - -- * Throws 'UnsupportedMediaType' if the Content-Type does not + -- * Aborts with 'unsupportedMediaType415' if 'hContentType' does not -- indicate a JSON payload. -- - -- * Throws 'BadRequest' if the payload fails to parse. + -- * Aborts with 'badRequest400' if the payload fails to parse. -- - -- * Throws 'PayloadTooLarge' if the payload size limit is exceeded. - -- Use 'setBodyLimit' to adjust the limit to your liking. + -- * Aborts with 'requestEntityTooLarge413' if reading next chunk would + -- exceed the allotted request body limit. See 'setBodyLimit' for more. -- - -- * Throws 'InternalError' is the body has already been consumed - -- and was not cached as JSON. + -- * Aborts with 'internalServerError500' if the body has already been + -- consumed and was not cached as JSON. -- getJSON :: (MonadAction m, FromJSON a) => m a getJSON = do @@ -615,7 +631,7 @@ where -- This is ideal, we already have what we need. BodyJSON value -> case fromJSON value of - Data.Aeson.Error err -> throwError BadRequest (cs err) + Data.Aeson.Error err -> abort badRequest400 [] (cs err) Data.Aeson.Success out -> return out -- Body has not been parsed yet. This is very good. @@ -624,7 +640,7 @@ where if matchMediaList ctype [ "application/json", "text/json" ] then return () - else throwError UnsupportedMediaType "Send some JSON!" + else abort unsupportedMediaType415 [] "Send some JSON!" -- Taint and read. setActionField aeBody BodyTainted @@ -632,7 +648,7 @@ where -- Try to parse. value <- case eitherDecode' body of - Left reason -> throwError BadRequest (cs reason) + Left reason -> abort badRequest400 [] (cs reason) Right value -> return value -- Cache and return. @@ -640,13 +656,13 @@ where -- Parse to the output type. case fromJSON value of - Data.Aeson.Error err -> throwError BadRequest (cs err) + Data.Aeson.Error err -> abort badRequest400 [] (cs err) Data.Aeson.Success out -> return out -- Now this is bad. We have already read the body, -- but not as a JSON. This is an internal error. _else -> do - throwError InternalError "Body has been parsed as a non-JSON." + abort internalServerError500 [] "Body has been parsed as non-JSON." -- | @@ -656,13 +672,16 @@ where -- uploades them to a temporary location and caches information -- about them so that 'getFiles' can return them separately. -- - -- * Throws 'UnsupportedMediaType' if the Content-Type does not + -- * Aborts with 'unsupportedMediaType415' if 'hContentType' does not -- indicate a form payload. -- - -- * Throws 'BadRequest' if the payload fails to parse. + -- * Aborts with 'badRequest400' if the payload fails to parse. -- - -- * Throws 'PayloadTooLarge' if the payload size limit is exceeded. - -- Use 'setBodyLimit' to adjust the limit to your liking. + -- * Aborts with 'requestEntityTooLarge413' if reading next chunk would + -- exceed the allotted request body limit. See 'setBodyLimit' for more. + -- + -- * Aborts with 'internalServerError500' if the body has already been + -- consumed and was not cached as form data. -- getFields :: (MonadAction m) => m [(Text, Text)] getFields = map cs2 <$> fst <$> getFormData @@ -722,13 +741,16 @@ where -- Backend for both 'getFields' and 'getFiles' that parses, -- caches and returns form data. -- - -- * Throws 'UnsupportedMediaType' if the Content-Type does not + -- * Aborts with 'unsupportedMediaType415' if 'hContentType' does not -- indicate a form payload. -- - -- * Throws 'BadRequest' if the payload fails to parse. + -- * Aborts with 'badRequest400' if the payload fails to parse. -- - -- * Throws 'PayloadTooLarge' if the payload size limit is exceeded. - -- Use 'setBodyLimit' to adjust the limit to your liking. + -- * Aborts with 'requestEntityTooLarge413' if reading next chunk would + -- exceed the allotted request body limit. See 'setBodyLimit' for more. + -- + -- * Aborts with 'internalServerError500' if the body has already been + -- consumed and was not cached as form data. -- getFormData :: (MonadAction m) => m FormData getFormData = do @@ -744,7 +766,7 @@ where getChunk <- getBodyChunkIO case bodyType of - Nothing -> throwError UnsupportedMediaType "Send some form!" + Nothing -> abort unsupportedMediaType415 [] "Send some form!" Just bt -> do -- Prepare for uploaded files finalization. rtis <- createInternalState @@ -764,7 +786,7 @@ where -- Now this is bad. We have already read the body, -- but not as a form. This is an internal error. _else -> do - throwError InternalError "Body has been parsed as a non-form." + abort internalServerError500 [] "Body has been parsed as non-form." -- | @@ -810,7 +832,7 @@ where -- Now this is bad. We have already read the body, -- but not as a raw data. This is an internal error. _else -> do - throwError InternalError "Body has already been parsed." + abort internalServerError500 [] "Body has already been parsed." -- Building Response ------------------------------------------------------- @@ -1107,7 +1129,7 @@ where Just resp -> resp _else -> do - throwError InternalError "Body has already been consumed." + abort internalServerError500 [] "Body has already been consumed." where app :: WS.PendingConnection -> IO () @@ -1167,10 +1189,24 @@ where -- | - -- Same an IO exception in the form of ('RequestError', 'Text'). + -- Raise 'AbortAction' using given status, headers and a textual body. + -- Headers are prefixed with ('hContentType', @text/plain@). + -- + -- You are supposed to use it together with 'abortMiddleware' that turns + -- such exception into regular 'Response's. + -- + abort :: (MonadIO m) => Status -> [Header] -> Text -> m a + abort st hdrs msg = throwIO $ AbortAction st hdrs' msg + where hdrs' = (hContentType, "text/plain; charset=utf8") : hdrs + + + -- | + -- Catches 'AbortAction' and turns it into a 'Response'. -- - throwError :: (MonadAction m) => RequestError -> Text -> m a - throwError exn msg = throwIO (exn, msg) + abortMiddleware :: Middleware + abortMiddleware app req sink = do + app req sink `catch` \AbortAction{..} -> do + sink $ responseLBS status headers (cs message) -- Localization ------------------------------------------------------------ diff --git a/lib/Hikaru/CSRF.hs b/lib/Hikaru/CSRF.hs index 1f07c29ce7235180a1ce8aaaa02cd0983611f221..7252242050dfe53a235cee7b73ba47a567f247c6 100644 --- a/lib/Hikaru/CSRF.hs +++ b/lib/Hikaru/CSRF.hs @@ -1,14 +1,14 @@ -{-| -Module : Hikaru.CSRF -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides CSRF mitigation utilities. --} +-- | +-- Module : Hikaru.CSRF +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides CSRF mitigation utilities. +-- module Hikaru.CSRF ( generateToken diff --git a/lib/Hikaru/Develop.hs b/lib/Hikaru/Develop.hs index a6a34d2ac6c0df0cedf49831eabd6e658db3da50..8129f818a9b60ca8d301c1ce86bbe23351d43161 100644 --- a/lib/Hikaru/Develop.hs +++ b/lib/Hikaru/Develop.hs @@ -1,14 +1,14 @@ -{-| -Module : Hikaru.Develop -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides development and testing utilities. --} +-- | +-- Module : Hikaru.Develop +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides development and testing utilities. +-- module Hikaru.Develop ( developWith diff --git a/lib/Hikaru/Dispatch.hs b/lib/Hikaru/Dispatch.hs index e82bc370361c3ac3f8fdfbfacd803e240aed742a..5a8c0d6114d495211ffd491cdb2aaab706a2fea3 100644 --- a/lib/Hikaru/Dispatch.hs +++ b/lib/Hikaru/Dispatch.hs @@ -1,247 +1,168 @@ -{-| -Module : Hikaru.Dispatch -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides monad-based request dispatching. --} +-- | +-- Module : Hikaru.Dispatch +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides means for dispatching on routes. +-- +-- For example: +-- +-- @ +-- demo :: 'Application' +-- demo = 'dispatch' id do +-- 'middleware' logStdout +-- 'handler' 404 notFound +-- 'route' getHelloR +-- 'route' getByeR +-- @ +-- module Hikaru.Dispatch - ( dispatch - - -- ** Routes + ( Dispatch + , dispatch , route - , wrapRoute - , wrapRoutes - , wrapAction - , wrapActions - - -- ** Middleware , middleware - - -- ** Error Handlers , handler - - -- * Types - , Dispatch - , Nested - , TopLevel ) where - import Praha + import Praha hiding (curry) - import Control.Monad.State - import Data.CaseInsensitive (original) - import Data.List (lookup, deleteBy, intersperse, reverse, map) + import Hikaru.Action (abortMiddleware) import Hikaru.Route - import Hikaru.Types + + import Network.HTTP.Types.Status import Network.HTTP.Types.Header import Network.Wai - import UnliftIO.Exception - import qualified Data.Map.Strict as Map + import Data.List (reverse, lookup, sortOn) -- | - -- Monad for the 'dispatch' function. + -- Since routes do not share a common type (due to their captured parameters + -- being part of their type), we cannot just pass them in a list. So for + -- convenience, there is a dispatcher monad that also helps with middleware + -- and error handler registration. -- - newtype Dispatch r l a + newtype Dispatch h a = Dispatch - { unDispatch :: State (Env r) a + { runDispatch :: State (Env h) a } deriving (Functor, Applicative, Monad) - -- | - -- Part of the top-level 'Dispatch' signature. - -- - data TopLevel - - - -- | - -- Part of the nested 'Dispatch' signature. - -- - data Nested - - - data Env r + data Env h = Env - { envRouteWrap :: Route r -> Route r - , envActionWrap :: r -> r - , envHandlers :: Map RequestError (RequestError -> Text -> r) - , envMiddleware :: Middleware - , envRoutes :: [Route r] -- in reverse order + { routes :: [Request -> Maybe (h, Score)] + , mwstack :: Middleware + , handlers :: [(Int, Response -> h)] } - initEnv :: Env r - initEnv = Env id id mempty id mempty + initEnv :: Env h + initEnv = Env { routes = [] + , mwstack = id + , handlers = [] + } -- | - -- Create an application out of routes, handlers and middleware. - -- - -- You can use 'route', 'handler', 'middleware' to register each of - -- these components respectively. - -- - -- @ - -- app :: Application - -- app = dispatch runAction $ do - -- 'middleware' $ logStdoutDev - -- - -- 'route' $ getRootR \<$ 'Hikaru.Route.get' - -- 'route' $ getHelloR \<$ 'Hikaru.Route.get' \<\/ "hello" \<*\> 'arg' + -- Try to dispatch on a route. -- - -- 'wrapRoute' needAuth $ do - -- 'route' $ getAdminR \<$ 'Hikaru.Route.get' \<\/ "admin" - -- 'route' $ postPassR \<$ 'Hikaru.Route.post' \<\/ "admin" \<\/ "password" + -- All routes are tried and scored for every request, + -- preferring the earlier one in case of a tie. -- - -- 'handler' 'NotFound' notFoundR - -- @ - -- - dispatch :: forall r. (r -> Application) - -> forall a. Dispatch r TopLevel a - -> Application - dispatch runner = build runner . flip execState initEnv . unDispatch - - - build :: forall r. (r -> Application) -> Env r -> Application - build runner Env{..} = envMiddleware app' - where - app' req resp = mw app req resp - where - (mw, app) = case selectRoute (reverse envRoutes) req of - RouteFailed exn msg vhs -> (addVary vhs, err exn msg) - RouteSuccess ac _qu vhs -> (addVary vhs, run ac) - - run :: r -> Application - run x req' resp' = catch (runner x req' resp') - (\(exn, msg) -> mw (err exn msg) req' resp') + route :: Route (ts :: [Type]) h -> Dispatch h () + route r = Dispatch do + let r' req = case routeApply (pathInfo req) r of + Just h -> Just (h, routeScore req r) + Nothing -> Nothing - err :: RequestError -> Text -> Application - err exn msg = case Map.lookup exn envHandlers of - Just eh -> runner (eh exn msg) - Nothing -> defaultHandler exn msg + -- Routes must be prepended since we will later sort them and + -- reverse the order, making the earlier matching routes come + -- out first. + modify \e@Env{..} -> e { routes = r' : routes } -- | - -- Make sure that the @Vary@ response header contains specified names. + -- Register middleware. -- - addVary :: [HeaderName] -> Middleware - addVary vs = if vs == [] then id else apply - where - apply = modifyResponse (mapResponseHeaders fixup) - fixup = modifyHeader "Vary" (maybe value (<> ", " <> value)) - value = mconcat $ intersperse ", " $ map original vs - - modifyHeader n fn hs = (n, v') : Data.List.deleteBy headerEq (n, v') hs - where v' = fn (Data.List.lookup n hs) - - headerEq (x, _) (y, _) = x == y + -- Middleware gets applied in the reverse order of its appearence, + -- but always after the error handler. + -- + middleware :: Middleware -> Dispatch h () + middleware mw = Dispatch do + modify \e@Env{..} -> e { mwstack = mwstack . mw } -- | - -- Register a route. + -- Register error handler. -- - -- When multiple routes match with the same quality coefficient, - -- the one registered first will be selected. + -- It gets called when some of the routes respond with given status code. + -- Middleware gets applied after the handler, not before. -- - route :: Route r -> Dispatch r l () - route rt = Dispatch do - modify \env@Env{..} -> - env { envRoutes = envRouteWrap (fmap envActionWrap rt) : envRoutes } + handler :: Int -> (Response -> h) -> Dispatch h () + handler code fn = Dispatch do + modify \e@Env{..} -> e { handlers = (code, fn) : handlers } -- | - -- Wrap all nested routes with a route transformer. + -- Perform the dispatching. + -- + -- Needs a runner function that converts whatever saturated routes produce + -- into a regular WAI 'Application'. -- - -- It is a little bit similar to the middleware, but route-specific and - -- with full access to the routing utilities. Can be used e.g. to apply - -- authentication to multiple routes at once or to update their content - -- negotiation parameters. + -- If no route matches or matching routes all fail during appraisal, an + -- error response gets generated. It is extremely simple, @text/plain@ + -- response with the status code and message repeated in the body. + -- You can register your own 'handler', though. -- - wrapRoute :: (Route r -> Route r) -> Dispatch r Nested a -> Dispatch r l () - wrapRoute wrapper disp = Dispatch do - modify \env -> - let env' = execState (unDispatch disp) - (env { envRouteWrap = envRouteWrap env . wrapper }) + dispatch :: (h -> Application) -> Dispatch h a -> Application + dispatch run Dispatch{runDispatch} req = do + let Env{..} = execState runDispatch initEnv - in env { envRoutes = envRoutes env' <> envRoutes env } + let good = mapMaybe ($ req) routes + best = reverse (sortOn snd good) + let app = case best of + (h, Suitable _):_ -> run h + (_, reason):_ -> err reason + [] -> err NotFound - -- | - -- Wrap all /following/ routes with a route transformer. - -- - wrapRoutes :: (Route r -> Route r) -> Dispatch r l () - wrapRoutes wrapper = Dispatch do - modify \env -> env { envRouteWrap = envRouteWrap env . wrapper } + let mwstack' = mwstack . handlerMW run handlers . abortMiddleware + mwstack' app req - -- | - -- Wrap all nested actions with an action transformer. - -- - wrapAction :: (r -> r) -> Dispatch r Nested a -> Dispatch r l () - wrapAction wrapper disp = Dispatch do - modify \env -> - let env' = execState (unDispatch disp) - (env { envActionWrap = envActionWrap env . wrapper }) - in env { envRoutes = envRoutes env' <> envRoutes env } + err :: Score -> Application + err BadRequest = respond status400 + err NotFound = respond status404 + err MethodNotAllowed = respond status405 + err UpgradeRequired = respond status426 + err NotAcceptable = respond status406 + err LengthRequired = respond status411 + err UnsupportedMediaType = respond status415 + err (Suitable _) = error "BUG: errored out with a Suitable" - -- | - -- Wrap all /following/ actions with an action transformer. - -- - -- This can come in handy e.g. to tune cache control: - -- - -- @ - -- app :: Application - -- app = 'dispatch' runAction $ do - -- 'wrapRoutes' ('Hikaru.Action.defaultHeader' hCacheControl "no-cache" >>) - -- - -- 'route' $ getRootR \<$ 'Hikaru.Route.get' - -- 'route' $ getHelloR \<$ 'Hikaru.Route.get' \<\/ "hello" \<*\> 'arg' - -- @ - -- - wrapActions :: (r -> r) -> Dispatch r l () - wrapActions wrapper = Dispatch do - modify \env -> env { envActionWrap = envActionWrap env . wrapper } + respond :: Status -> Application + respond st@Status{..} _ sink = sink $ responseLBS st hdr msg + where + hdr = [(hContentType, "text/plain")] + msg = cs (show statusCode) <> " " <> cs statusMessage - -- | - -- Register a middleware. - -- - -- They are applied in the reverse order of their registation, - -- i.e. the first one to be registered is the last one to be applied. - -- - -- Middleware can only be registered at the top level of the dispatcher. - -- - middleware :: Middleware -> Dispatch r TopLevel () - middleware mw = Dispatch do - modify \env@Env{envMiddleware} -> - env { envMiddleware = envMiddleware . mw } + handlerMW :: (h -> Application) -> [(Int, Response -> h)] -> Middleware + handlerMW run handlers app req sink = do + app req \resp -> do + let Status{..} = responseStatus resp - -- | - -- Register a 'RequestError' handler. - -- - -- The handler is passed the original request and is supposed to - -- inform the user about the issue. It should use the correct HTTP - -- status code. - -- - -- If you need to perform content negotiation in the handler, - -- you can use 'dispatch' in it as well. Just make sure to provide a - -- route that cannot fail. - -- - handler :: RequestError - -> (RequestError -> Text -> r) - -> Dispatch r TopLevel () - handler e h = Dispatch do - modify \env@Env{envHandlers} -> - env { envHandlers = Map.insert e h envHandlers } + case lookup statusCode handlers of + Just fn -> run (fn resp) req sink + Nothing -> sink resp -- vim:set ft=haskell sw=2 ts=2 et: diff --git a/lib/Hikaru/Form.hs b/lib/Hikaru/Form.hs index 8ffac6168aed4de39d969b7ce101a5d29c55eafe..f3cae9b1c7ae4eeb67ea0a6ceebe39520da3e211 100644 --- a/lib/Hikaru/Form.hs +++ b/lib/Hikaru/Form.hs @@ -1,29 +1,28 @@ -{-| -Module : Hikaru.Form -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides tools for building localized HTML forms -with server-side validation. - -There are three important steps involved: - -1. A 'Form' needs to be defined first. For that you need to supply the - underlying data type as well as high-level description of the form - elements. - -2. It needs be fed some request data. You control what data by using - 'newForm', 'getForm' or 'postForm'. - -3. It needs to be rendered as HTML. This module will leave you with a - high-level form 'View', but you need to take care of the rendering - yourself. - --} +-- | +-- Module : Hikaru.Form +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides tools for building localized HTML forms +-- with server-side validation. +-- +-- There are three important steps involved: +-- +-- 1. A 'Form' needs to be defined first. For that you need to supply the +-- underlying data type as well as high-level description of the form +-- elements. +-- +-- 2. It needs be fed some request data. You control what data by using +-- 'newForm', 'getForm' or 'postForm'. +-- +-- 3. It needs to be rendered as HTML. This module will leave you with a +-- high-level form 'View', but you need to take care of the rendering +-- yourself. +-- module Hikaru.Form ( @@ -118,7 +117,9 @@ where , envValue :: Maybe o , envValidate :: Bool } - deriving (Show) + deriving (Show, Generic) + + instance (NFData o) => NFData (Env o) -- | @@ -132,7 +133,9 @@ where { viewElements :: [Element l] , viewControls :: [Control l] } - deriving (Show) + deriving (Show, Generic) + + instance (NFData l) => NFData (View l) -- | @@ -190,7 +193,9 @@ where { elemLabel :: l , elemControls :: [Control l] } - deriving (Show) + deriving (Show, Generic) + + instance (NFData l) => NFData (Element l) -- | @@ -228,7 +233,10 @@ where , ctrlNotes :: [Note l] , ctrlHints :: [Dynamic] } - deriving (Show) + deriving (Show, Generic) + + instance (NFData l) => NFData (Control l) where + rnf Control{..} = rnf (ctrlName, ctrlField, ctrlNotes) data ControlState l v m @@ -239,6 +247,10 @@ where , csValidators :: [Maybe v -> m [Note l]] , csValue :: Maybe v } + deriving (Generic) + + instance (NFData l, NFData v) => NFData (ControlState l v m) where + rnf ControlState{..} = rnf (csName, csField, csValidators, csValue) instance (Show l, Show v) => Show (ControlState l v m) where show ControlState{..} = @@ -267,9 +279,7 @@ where lift = ControlT . lift . lift {-# INLINE lift #-} - instance (MonadAction m) => MonadAction (ControlT t l o v m) where - getActionEnv = ControlT $ lift $ lift $ getActionEnv - {-# INLINE getActionEnv #-} + instance (MonadAction m) => MonadAction (ControlT t l o v m) -- | @@ -281,7 +291,9 @@ where { noteSeverity :: Severity , noteMessage :: l } - deriving (Show) + deriving (Show, Generic) + + instance (NFData l) => NFData (Note l) -- | @@ -299,7 +311,9 @@ where | SelectField { fieldOptions :: [Option l] } - deriving (Show) + deriving (Show, Generic) + + instance (NFData l) => NFData (Field l) -- | @@ -308,7 +322,9 @@ where data FieldTag = InputFieldTag | SelectFieldTag - deriving (Show, Eq) + deriving (Show, Eq, Generic) + + instance NFData FieldTag -- | @@ -320,7 +336,9 @@ where , optionSelected :: Bool , optionValue :: Text } - deriving (Show) + deriving (Show, Generic) + + instance (NFData l) => NFData (Option l) class ToOption l o where @@ -380,7 +398,9 @@ where data FormMessage = FormMsgFieldRequired | FormMsgTokenInvalid - deriving (Show) + deriving (Show, Generic) + + instance NFData FormMessage instance Localizable FormMessage where -- Czech strings diff --git a/lib/Hikaru/Link.hs b/lib/Hikaru/Link.hs index e991ec8c90431aec90254af4d24f53155c2a5460..eae4ab15f9ce71737273f849c27e31bb2cc52bec 100644 --- a/lib/Hikaru/Link.hs +++ b/lib/Hikaru/Link.hs @@ -1,127 +1,114 @@ -{-| -Module : Hikaru.Link -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides various ways to build local links and provide -feedback based on the request path. --} +-- | +-- Module : Hikaru.Link +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides various ways to build local links and provide +-- feedback based on the request path. +-- module Hikaru.Link - ( makeLink - , deriveLink - - -- ** Lucid Integration - , lhref_ - , phref_ - , qhref_ - - -- ** Path Feedback - , isActivePath - , isActivePrefix + ( rhref + , rhref_ + , updateQuery + , isActiveRoute ) where - import Praha + import Praha hiding (curry) + + import Hikaru.Action + import Hikaru.Route + import Hikaru.Types + + import Network.HTTP.Types.URI import Data.Binary.Builder + import Data.List (map, filter) + import Data.Maybe (isJust) + + import Data.HVect + import Lucid - import Network.HTTP.Types.URI - import Hikaru.Action - import Data.List (isPrefixOf, map, filter) -- | - -- Combine path segments and parameters to create an internal Link. + -- Construct a link with a query string to the given route, + -- using given values for captured segments. -- - -- Examples: + -- Example: -- - -- >>> makeLink ["api", "echo"] [] - -- "/api/echo" - -- >>> makeLink ["char", ""] [("name", "haruhi")] - -- "/char/?name=haruhi" + -- >>> rhref getEchoR "Hi" [("q", "42")] + -- "/echo/Hi?q=42" -- - makeLink :: [Text] -> [(Text, Text)] -> Text - makeLink ps qs = cs $ toLazyByteString $ encodePath ps $ csQueryTuple qs + rhref :: (HasRep ts, AllHave Param ts) + => Route ts a + -> HVectElim ts ([(Text, Text)] -> Text) + rhref route = curry (hvRhref route) + + + hvRhref :: (AllHave Param ts) + => Route ts a -> HVect ts -> [(Text, Text)] -> Text + hvRhref route xs qs = buildLink route xs qs -- | - -- Create a link with just the query string by updating the - -- parameters sent by the client. + -- Similar to 'rhref', but inteded to be used with Lucid. -- - -- All keys that appear in the new parameter list are first deleted - -- from the current parameter list, then the new list is appended to - -- the current one. + -- Example: -- - -- Useful to create dynamic pages with multiple independent widgets. + -- @ + -- qs <- updateQuery [("conv", "upper")] + -- 'a_' ['rhref_' getEchoR "Hi" qs] ... + -- @ -- - deriveLink :: (MonadAction m) => [(Text, Text)] -> m Text - deriveLink ps = do - ops <- getParams - return $ makeLink [] $ update ops ps + rhref_ :: (HasRep ts, AllHave Param ts) + => Route ts a + -> HVectElim ts ([(Text, Text)] -> Attribute) + rhref_ route = curry (hvRhref_ route) - csQueryTuple :: [(Text, Text)] -> [(ByteString, Maybe ByteString)] - csQueryTuple = map \(n, v) -> (cs n, Just (cs v)) + hvRhref_ :: (AllHave Param ts) + => Route ts a -> HVect ts -> [(Text, Text)] -> Attribute + hvRhref_ route xs qs = href_ (buildLink route xs qs) - update :: [(Text, Text)] -> [(Text, Text)] -> [(Text, Text)] - update old new = deleteNewKeys old <> new + buildLink :: (AllHave Param ts) + => Route ts a -> HVect ts -> [(Text, Text)] -> Text + buildLink route xs qs = cs (toLazyByteString (path <> query)) where - deleteNewKeys = filter \(n, _) -> n `notElem` newKeys - newKeys = map fst new + path = case routeLinkHVect route xs of + [] -> "/" + ps -> encodePathSegments ps - - -- Lucid Integration ------------------------------------------------------- + query = renderQueryBuilder True $ + flip map qs \(n, v) -> (cs n, Just (cs v)) -- | - -- Create a @href@ attribute using 'makeLink'. + -- Update query string from the original request with supplied values. -- - lhref_ :: [Text] -> [(Text, Text)] -> Attribute - lhref_ ps qs = href_ (makeLink ps qs) - {-# INLINE lhref_ #-} - - - -- | - -- Same as 'lhref_', but without any query parameters. + -- First, all keys in the supplied query are removed from the original, + -- then the supplied query gets appended to the original. -- - phref_ :: [Text] -> Attribute - phref_ ps = href_ (makeLink ps []) - {-# INLINE phref_ #-} - - - -- | - -- Same as 'lhref_', but without any path components. - -- - qhref_ :: [(Text, Text)] -> Attribute - qhref_ qs = href_ (makeLink [] qs) - {-# INLINE qhref_ #-} + updateQuery :: (MonadAction m) => [(Text, Text)] -> m [(Text, Text)] + updateQuery query' = do query <- filter (not. updated) <$> getParams + return (query <> query') + where + updated (key, _) = key `elem` map fst query' -- Path Feedback ----------------------------------------------------------- -- | - -- Determine whether the supplied path is the one user has requested. - -- - isActivePath :: (MonadAction m) => [Text] -> m Bool - isActivePath link = do - path <- getPathInfo - return $ link == path - - - -- | - -- Determine whether the supplied path is a prefix of the one user has - -- requested. Empty path components in the supplied path are ignored. + -- Determine whether the supplied route is the one user has requested. -- - isActivePrefix :: (MonadAction m) => [Text] -> m Bool - isActivePrefix link = do - path <- getPathInfo - return $ isPrefixOf (filter (/= "") link) path + isActiveRoute :: (MonadAction m) => Route ts a -> m Bool + isActiveRoute route = isJust <$> flip routeApply route <$> getPathInfo -- vim:set ft=haskell sw=2 ts=2 et: diff --git a/lib/Hikaru/Localize.hs b/lib/Hikaru/Localize.hs index 0ea3a4fd59a6cb6b058848e18d635b57d4a8c65e..3c540316911aa2cab22471a41cddfa561ffea925 100644 --- a/lib/Hikaru/Localize.hs +++ b/lib/Hikaru/Localize.hs @@ -1,60 +1,60 @@ -{-| -Module : Hikaru.Localize -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides support for website localization. - -First, you need to create a message catalog: - -@ --- All messages we want to localize. -data SampleMessages - = MsgSuccess - | MsgFailure - deriving (Show) - --- Default HTML rendering of the messages. -instance 'ToHtml' SampleMessages where - 'toHtmlRaw' = 'toHtml' - --- Language-specific rendering of those messages. -instance Localizable SampleMessages where - -- English variants - 'localize' \"en\" MsgSuccess = 'Just' \"Success\" - 'localize' \"en\" MsgFailure = 'Just' \"Failure\" - - -- Czech variants - 'localize' \"cs\" MsgSuccess = 'Just' \"Úspěch\" - 'localize' \"cs\" MsgFailure = 'Just' \"Selhání\" - - -- Otherwise try the next locale - 'localize' _locale _msg = 'Nothing' -@ - -Next, create a preferred language list for every action: - -@ -'Hikaru.Dispatch.dispatch' runAction $ do - 'Hikaru.Dispatch.wrapAction' ('selectLanguages' \"lang\" \"lang\" >>) $ do - 'Hikaru.Dispatch.route' ... -@ - -Finally, you can use your catalog when rendering pages: - -@ -getSampleR :: 'Bool' -> Action () -getSampleR flag = do - 'sendHTML' $ do - if flag - then lc_ MsgSuccess - else lc_ MsgFailure -@ --} +-- | +-- Module : Hikaru.Localize +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides support for website localization. +-- +-- First, you need to create a message catalog: +-- +-- @ +-- -- All messages we want to localize. +-- data SampleMessages +-- = MsgSuccess +-- | MsgFailure +-- deriving (Show) +-- +-- -- Default HTML rendering of the messages. +-- instance 'ToHtml' SampleMessages where +-- 'toHtmlRaw' = 'toHtml' +-- +-- -- Language-specific rendering of those messages. +-- instance Localizable SampleMessages where +-- -- English variants +-- 'localize' \"en\" MsgSuccess = 'Just' \"Success\" +-- 'localize' \"en\" MsgFailure = 'Just' \"Failure\" +-- +-- -- Czech variants +-- 'localize' \"cs\" MsgSuccess = 'Just' \"Úspěch\" +-- 'localize' \"cs\" MsgFailure = 'Just' \"Selhání\" +-- +-- -- Otherwise try the next locale +-- 'localize' _locale _msg = 'Nothing' +-- @ +-- +-- Next, create a preferred language list for every action: +-- +-- @ +-- 'Hikaru.Dispatch.dispatch' runAction $ do +-- 'Hikaru.Dispatch.wrapAction' ('selectLanguages' \"lang\" \"lang\" >>) $ do +-- 'Hikaru.Dispatch.route' ... +-- @ +-- +-- Finally, you can use your catalog when rendering pages: +-- +-- @ +-- getSampleR :: 'Bool' -> Action () +-- getSampleR flag = do +-- 'sendHTML' $ do +-- if flag +-- then lc_ MsgSuccess +-- else lc_ MsgFailure +-- @ +-- module Hikaru.Localize ( Locale @@ -157,8 +157,8 @@ where preferred <- getParamMaybe paramName previous <- getCookieMaybe cookieName acceptable <- getAcceptLanguage - <&> filter ((> 0) . mediaQuality) - <&> map mediaMainType + <&> filter ((> 0) . quality) + <&> map mainType case preferred of Nothing -> return () diff --git a/lib/Hikaru/Media.hs b/lib/Hikaru/Media.hs index 0b045c35e8424fc2652db9ee17c442d638cecc45..028d13da5b23b9ddadc26e96a4f1500282703ae1 100644 --- a/lib/Hikaru/Media.hs +++ b/lib/Hikaru/Media.hs @@ -1,16 +1,16 @@ -{-| -Module : Hikaru.Media -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides means for parsing HTTP media lists that can be found -in the headers such as @Accept@, @Accept-Charset@, @Accept-Encoding@ and -@Accept-Language@. --} +-- | +-- Module : Hikaru.Media +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides means for parsing HTTP media lists that can be found +-- in headers such as @Accept@, @Accept-Charset@, @Accept-Encoding@ and +-- @Accept-Language@. +-- module Hikaru.Media ( Media(..) @@ -29,7 +29,7 @@ where import Data.Text (toLower) - import Data.List (filter, lookup, head, sortOn) + import Data.List (filter, lookup, sortOn) import Text.ParserCombinators.ReadP import Data.Char (isControl, isSpace) @@ -38,23 +38,27 @@ where -- Single media element. -- -- When used for media without a subtype (such as languages or encodings), - -- the respective field is just @""@ while quality defaults to @1.0@. + -- the subType field is just @""@ while quality defaults to @1.0@. -- data Media = Media - { mediaMainType :: Text - , mediaSubType :: Text - , mediaParams :: [(Text, Text)] - , mediaQuality :: Float + { mainType :: Text + , subType :: Text + , params :: [(Text, Text)] + , quality :: Float } - deriving (Show, Eq, Ord) + deriving (Show, Eq, Ord, Generic) + + instance NFData Media -- | -- You can construct media elements using @OverloadedStrings@. -- Just be careful - invalid syntax means a runtime error. -- instance IsString Media where - fromString = head . parseMedia . cs + fromString str = case parseMedia (cs str) of + m:_ -> m + _else -> error $ "Failed to parse media " <> show str -- | @@ -64,13 +68,13 @@ where -- Example: -- -- >>> parseMedia "text/html, text/plain;q=0.7" - -- [ Media { mediaMainType = "text", mediaSubType = "html", mediaQuality = 1.0 } - -- , Media { mediaMainType = "text", mediaSubType = "plain", mediaQuality = 0.7 } + -- [ Media { mainType = "text", subType = "html", quality = 1.0, params = [] } + -- , Media { mainType = "text", subType = "plain", quality = 0.7, params = [] } -- ] -- parseMedia :: Text -> [Media] parseMedia text = case readP_to_S pMediaList (cs (toLower text)) of - (m, ""):_ -> sortOn (negate . mediaQuality) m + (m, ""):_ -> sortOn (negate . quality) m _else -> [] @@ -82,14 +86,14 @@ where pMediaList = sepBy pMedia pSeparator <* eof where pMedia = do - mediaMainType <- cs <$> pToken - _ <- char '/' - mediaSubType <- cs <$> pToken - mediaParams <- many pParameter + mainType <- cs <$> pToken + _ <- char '/' + subType <- cs <$> pToken + params <- many pParameter - let mediaQuality = fromMaybe 1.0 do - q <- lookup "q" mediaParams - readMaybe (cs q) + let quality = fromMaybe 1.0 do + q <- lookup "q" params + readMaybe (cs q) return Media{..} @@ -129,13 +133,10 @@ where -- False -- matchMedia :: Media -> Media -> Bool - matchMedia l r = mainMatches && subMatches && mediaQuality r > 0.0 + matchMedia l r = mainMatches && subMatches && quality r > 0.0 where - mainMatches = mediaMainType l == mediaMainType r - || mediaMainType r == "*" - - subMatches = mediaSubType l == mediaSubType r - || mediaSubType r == "*" + mainMatches = mainType l == mainType r || mainType r == "*" + subMatches = subType l == subType r || subType r == "*" -- | @@ -161,9 +162,9 @@ where selectMedia :: [Media] -> [Media] -> Maybe Media selectMedia ls rs = case best of [ ] -> Nothing - (l, r):_ -> Just $ l { mediaQuality = mediaQuality r } + (l, r):_ -> Just $ l { quality = quality r } where - best = sortOn (negate . mediaQuality . snd) good + best = sortOn (negate . quality . snd) good good = filter (uncurry matchMedia) prod prod = [(l, r) | l <- ls, r <- rs] diff --git a/lib/Hikaru/Route.hs b/lib/Hikaru/Route.hs index 200cd667d87aa1b69decea44ca9ed422377bcb9e..a51b7b420f5b1620d4baece142c01e59e142d829 100644 --- a/lib/Hikaru/Route.hs +++ b/lib/Hikaru/Route.hs @@ -1,396 +1,373 @@ -{-| -Module : Hikaru.Route -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides path matching with parameter extraction -as well as content negotiation through path quality scoring. --} +-- | +-- Module : Hikaru.Route +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- This module provides means for route construction. +-- module Hikaru.Route - ( - -- * Route Selection - selectRoute - , RouteResult(..) - - -- ** Path Matching - , seg - , (</) - , arg - , argWith - , rest - - -- ** Route Scoring - , vary - , score + ( -- * Path Matching + Route + , root + , (/:) + , (//) + , (/?) + + -- * Route Scoring + , Appraisal(..) , Score(..) - -- *** Method + -- ** Method (HTTP Verb) , method , get , post - , head - , put - , patch - , delete - -- *** WebSockets + -- ** WebSockets + , requireWebsocket , websocket - -- *** Request Content + -- ** Request Content , acceptContent , acceptForm , acceptJSON - -- *** Response Content + -- ** Response Content , offerContent , offerHTML , offerText , offerJSON - , offerCharset , offerEncoding , offerLanguage - -- ** Types - , Route + -- * Applying Routes + , routePath + , PathInfo(..) + , routeLink + , routeLinkHVect + , routeApply + , routeScore + , routeVary ) where - import Praha - - import Data.List (lookup, map) + import Praha hiding (curry) import Hikaru.Media import Hikaru.Types + import Network.HTTP.Types.Header - import Network.HTTP.Types.Method (Method) import Network.Wai import Network.Wai.Handler.WebSockets + import Data.List (reverse, nub, lookup) + import Data.Typeable (TypeRep, typeRep) + + import Data.HVect hiding (reverse) + -- | - -- 'Applicative' used to associate a handler with conditions, - -- upon which to invoke it. + -- Route combines path description with a handler that gets saturated + -- with captured path components. + -- + -- Just make a path like this: + -- + -- @ + -- getHelloR :: 'Route' \'[Text, Text] Text + -- getHelloR = 'get' handler '//' \"hello\" '/:' \"greeting\" '/:' \"name\" + -- where handler greeting name = mconcat [ greeting, \", \", name, \"!\" ] + -- @ + -- + -- And then apply it to a list of path components, like that: + -- + -- >>> routeApply ["hello", "What a nice day", "dear Reader"] getHelloR + -- Just "What a nice day, dear Reader!" -- - -- Frequent conditions include HTTP method and path matching, - -- but can also include an arbitrary 'Request' inspection + -- Or construct a link to it with 'routeLink': -- - newtype Route a + -- >>> href getHelloR "Ahoy" "Sailor" + -- ["hello", "Ahoy", Sailor"] + -- + -- Or better yet, use 'Hikaru.Dispatch.dispatch' to select the best + -- route to handle a 'Request'. + -- + data Route ts a = Route - { runRoute :: Env -> (Env, Maybe a) + { path :: [PathInfo] + , func :: [Text] -> Maybe a + , score :: [Request -> Score] + , vary :: [HeaderName] } + deriving (Generic) - instance Functor Route where - fmap f r = Route \env -> fmap (fmap f) (runRoute r env) - {-# INLINE fmap #-} - - instance Applicative Route where - pure x = Route \env -> (env, Just x) - {-# INLINE pure #-} + instance NFData (Route ts a) - (<*>) rf rx = Route \env -> - case runRoute rf env of - (env', Just f) -> runRoute (fmap f rx) env' - (env', Nothing) -> case runRoute rx env' of - (env'', _) -> (env'', Nothing) - {-# INLINE (<*>) #-} + data PathInfo + = Segment Text + -- ^ Path segment to be matched. + | Capture Text TypeRep + -- ^ Path segment to be captured. + deriving (Show, Eq, Generic) - -- | - -- Internal 'Route' environment. - -- - data Env - = Env - { envPath :: [Text] - -- ^ Remaining path segments to consume during matching. - - , envScoring :: [Request -> Score] - -- ^ Registered scoring functions to select the best handler. - - , envVary :: [HeaderName] - -- ^ List of headers used to score this route. - } + instance NFData PathInfo -- | - -- Route scoring result. - -- - -- Scores are ordered in the descending order of preference. + -- Indicates suitability as well as quality of a route. -- - -- Route matching results in the worst result encountered in the route. - -- Dispatcher then selects the route with the best score overall. - -- Best match does not necessarily mean a successfull one, though. + -- Scores form a monoid, added together the smaller one wins. + -- When two Suitable ones are added, their quality gets multiplied. + -- Since the default quality is 1.0 and quality (when negotiating content) + -- should be in the \((0.0, 1.0]\) range, this helps to select appropriate + -- route across multiple appraisals. -- data Score - = Suitable !Float - | Unsuitable !RequestError Text - deriving (Eq, Ord) + = BadRequest + | NotFound + | MethodNotAllowed + | UpgradeRequired + | NotAcceptable + | LengthRequired + | UnsupportedMediaType + | Suitable Float + -- ^ Suitable routes have quality. + deriving (Show, Eq, Ord, Generic) + + instance NFData Score - - -- | - -- Concatenating two scores produces the worse one. - -- If both are suitable, the quality is multiplied. - -- instance Semigroup Score where (Suitable x) <> (Suitable y) = Suitable (x * y) - x <> y = max x y - {-# INLINE (<>) #-} + x <> y = min x y - -- | - -- Results form a monoid with 'Suitable' as the neutral element. - -- instance Monoid Score where mempty = Suitable 1.0 - {-# INLINE mempty #-} -- | - -- Result of a route matching. + -- Appraisal of a route to decide whether it's suitable for a 'Request' + -- or not. -- - -- * 'RequestError' is used to indicate the reason of failure. + -- Some appraisals (especially those for content negotiation) inspect + -- request headers. HTTP spec mandates that any 'Response' that serves + -- different content based on headers (e.g. in a different language) to + -- always include a @Vary@ header with names of all headers used in this + -- manner. You can get the list for a route using 'routeVary'. -- - -- * Successfull value is annotated with headers used to select it - -- and the final quality. You should send them to the user using - -- the @Vary@ header. + -- You should mention those headers when designing your own appraisals, + -- by the way. -- - data RouteResult a - = RouteFailed - { rresError :: RequestError - , rresMessage :: Text - , rresVary :: [HeaderName] + data Appraisal + = Appraisal + { score :: Request -> Score + , vary :: [HeaderName] } - | RouteSuccess - { rresAction :: a - , rresQuality :: Float - , rresVary :: [HeaderName] - } - - -- | - -- It forms a semigroup that selects the best result overall. - -- - instance Semigroup (RouteResult a) where - x@(RouteSuccess _ g _) <> y@(RouteSuccess _ h _) = if g >= h then x else y - x@(RouteSuccess _ _ _) <> ( RouteFailed _ _ _) = x - ( RouteFailed _ _ _) <> y@(RouteSuccess _ _ _) = y - x@(RouteFailed g _ _) <> y@(RouteFailed h _ _) = if g >= h then x else y + deriving (Generic) + instance NFData Appraisal - -- | - -- Given a list of available routes and a 'Request' - -- return the action to take or give a reason for failure. - -- - selectRoute :: [Route a] -> Request -> RouteResult a - selectRoute rs req = choosePath req $ mapMaybe bind rs - where bind = flip bindPath (pathInfo req) + type family RouteElim (r :: Type) (ts :: [Type]) where + RouteElim r (t ': ts) = t -> RouteElim r ts + RouteElim r '[] = r - -- | - -- Map action to path components. - -- - bindPath :: Route a -> [Text] -> Maybe (Request -> RouteResult a) - bindPath rt p = case runRoute (rt <* end) (Env p [] []) of - (_, Nothing) -> Nothing - (env, Just x) -> Just (scoreResult env x) + infixl 4 /: -- | - -- Score the mapped action with respect to the request. + -- Extends the route with a captured component that gets automatically + -- converted using 'fromParam' behind the scenes when saturating + -- the handler. -- - scoreResult :: Env -> a -> Request -> RouteResult a - scoreResult Env{..} x req = - case mconcat (map (req &) envScoring) of - Unsuitable exn msg -> RouteFailed exn msg envVary - Suitable score' -> if score' <= 0.0 - then RouteFailed NotAcceptable "" envVary - else RouteSuccess x score' envVary - - - -- | - -- Select the best route with respect to the request. + -- The 'Text' argument itself can be obtained using 'routePath' later, + -- when you want to introspect the route for some reason. It serves no + -- other purpose. -- - choosePath :: Request -> [Request -> RouteResult a] -> RouteResult a - choosePath req = choose . map ($ req) + (/:) :: forall ts a b. (Param a, Typeable a) + => Route ts (a -> b) -> Text -> Route (Reverse (a ': Reverse ts)) b + (/:) r@Route{..} name = r { path = capture : path + , func = apply + } where - choose :: [RouteResult a] -> RouteResult a - choose [] = RouteFailed NotFound "" [] - choose (r:rs) = sconcat (r :| rs) + capture = Capture name (typeRep proxy) + proxy = Proxy :: Proxy a + apply (this:rest) = func rest <*> fromParam this + apply _otherwise = Nothing - -- Path Matching ----------------------------------------------------------- + infixl 4 // -- | - -- Match and return following path segment. + -- Extends the route with a matched component. -- - -- Fails with 'NotFound' if the segment is missing or different. - -- - seg :: Text -> Route Text - seg s = argWith \t -> if s == t - then Just t - else Nothing + (//) :: Route ts a -> Text -> Route ts a + (//) r@Route{..} seg = r { path = Segment seg : path + , func = apply + } + where + apply (this:rest) = if this == seg then func rest else Nothing + apply _otherwise = Nothing - -- | - -- Shortcut to append a path segment in a more readable way. - -- - (</) :: Route a -> Text -> Route a - (</) r t = r <* seg t + infixl 4 /? -- | - -- Match, parse and return following path segment. - -- - -- Fails with 'NotFound' if the segment is missing or unparseable. + -- Extends the route with an appraisal. That is, an additional condition + -- (apart from path) that the route must satisfy in order to be used when + -- dispatching or with 'routeScore'. -- - arg :: (Param a) => Route a - arg = argWith fromParam + (/?) :: Route ts a -> Appraisal -> Route ts a + (/?) r@Route{..} Appraisal{score = score', vary = vary'} = + r { vary = nub (vary' <> vary) + , score = score' : score + } -- | - -- Match, parse and return next path segment using the supplied function. - -- - -- Fails with 'NotFound' if the segment is missing. - -- Otherwise respects the result of the matcher function. + -- Ties an empty path to an unsaturated handler. -- - argWith :: (Text -> Maybe a) -> Route a - argWith match = - Route \env@Env{..} -> - case envPath of - [] -> (env, Nothing) - (x:xs) -> (env { envPath = xs }, match x) + root :: handler -> Route '[] handler + root x = Route { path = [] + , score = [] + , vary = [] + , func = \case + [] -> Just x + _else -> Nothing + } -- | - -- Match and return all remaining path segments. + -- Check that the request used given HTTP verb. -- - rest :: Route [Text] - rest = Route \env@Env{..} -> (env { envPath = [] }, Just envPath) + method :: ByteString -> Appraisal + method verb = Appraisal {vary = [], score} + where + score req = if requestMethod req == verb + then Suitable 1.0 + else MethodNotAllowed -- | - -- Match end of the path (i.e. no remaining segments). - -- Used to ensure that the path has been exhausted. - -- - -- Fails with 'NotFound' if more segments are remaining. + -- Combines 'root' with 'method' for the most common case. -- - end :: Route () - end = Route \env@Env{..} -> - case envPath of - [] -> (env, Just ()) - _ -> (env, Nothing) + get :: handler -> Route '[] handler + get fn = root fn /? method "GET" -- | - -- Score route with respect to the request using the supplied function. + -- Combines 'root' with 'method' for the second most common case. -- - score :: (Request -> Score) -> Route () - score fn = - Route \env@Env{..} -> - (env { envScoring = fn : envScoring }, Just ()) + post :: handler -> Route '[] handler + post fn = root fn /? method "POST" -- | - -- Add list of headers that have been inspected in order to - -- select proper response format. + -- Check that the request wants to perform an upgrade to WebSocket. -- - -- This is needed to ensure our responses are cached properly by any - -- proxies along the way, but you only need to use this function if - -- your action performs some kind of additional content negotiation. - -- All the scoring functions in this module do this automatically. + -- Varies with 'hUpgrade'. -- - vary :: [HeaderName] -> Route () - vary hs = - Route \env@Env{..} -> - (env { envVary = hs <> envVary }, Just ()) - - - -- Methods ----------------------------------------------------------------- + requireWebsocket :: Appraisal + requireWebsocket = Appraisal {vary = [hUpgrade], score} + where + score req = if isWebSocketsReq req + then Suitable 1.0 + else UpgradeRequired -- | - -- Match a particular HTTP method. + -- Combines 'root' with 'requireWebsocket' for a common case. -- - -- Fails with 'MethodNotAllowed' if a different method was used. - -- - method :: Method -> Route () - method meth = score \req -> - if meth == requestMethod req - then Suitable 1.0 - else Unsuitable MethodNotAllowed "" + websocket :: a -> Route '[] a + websocket fn = root fn /? requireWebsocket -- | - -- Same as 'method' with the @GET@ argument. + -- Return information about matched and captured path components + -- for given route. Might come in handy for automated API docs. -- - get :: Route () - get = method "GET" + routePath :: Route ts a -> [PathInfo] + routePath Route{path} = reverse path -- | - -- Same as 'method' with the @POST@ argument. + -- Apply route to a path, ideally resulting in a saturated handler. -- - post :: Route () - post = method "POST" + routeApply :: [Text] -> Route ts a -> Maybe a + routeApply xs Route{func} = func (reverse xs) -- | - -- Same as 'method' with the @HEAD@ argument. + -- Score route for given 'Request'. -- - head :: Route () - head = method "HEAD" + routeScore :: Request -> Route ts a -> Score + routeScore req Route{score} = mconcat $ fmap ($ req) score -- | - -- Same as 'method' with the @PUT@ argument. + -- Get list of all headers 'routeScore' would use. -- - put :: Route () - put = method "PUT" + routeVary :: Route ts a -> [HeaderName] + routeVary Route{vary} = vary -- | - -- Same as 'method' with the @PATCH@ argument. + -- Construct link for the given route using supplied values of captured + -- path components. Useful to create hrefs and form actions. -- - patch :: Route () - patch = method "PATCH" + routeLink :: forall ts a. (HasRep ts, AllHave Param ts) + => Route ts a -> HVectElim ts [Text] + routeLink route = curry (routeLinkHVect route) -- | - -- Same as 'method' with the @DELETE@ argument. + -- Same as 'routeLink', but operates on a heterogenous vector instead. + -- Useful to create your own 'routeLink'-like functions. + -- See 'curry' for more. -- - delete :: Route () - delete = method "DELETE" + routeLinkHVect :: forall ts a. (AllHave Param ts) + => Route ts a -> HVect ts -> [Text] + routeLinkHVect Route{path} = buildPath (reverse path) - -- | - -- Match a WebSocket upgrade request. - -- - websocket :: Route () - websocket = score \req -> - if isWebSocketsReq req - then Suitable 1.0 - else Unsuitable BadRequest "WebSocket Upgrade Expected" + buildPath :: forall ts. (AllHave Param ts) + => [PathInfo] -> HVect ts -> [Text] + buildPath [] _ = [] + buildPath (Segment seg : more) xs = seg : buildPath more xs + buildPath (Capture _ _ : more) (x :&: xs) = toParam x : buildPath more xs + buildPath _ HNil = error "BUG: buildPath ran out of captures" -- | -- Check that the content sent by the client is among the listed -- media types and fail with 'UnsupportedMediaType' if not. - -- Adds @Vary: Content-Type@. -- - acceptContent :: [Media] -> Route () - acceptContent media = - vary [hContentType] <* score \req -> - let header = parseMedia (cs $ getContentType req) - in case selectMedia media header of - Nothing -> Unsuitable UnsupportedMediaType "" - Just md -> Suitable (mediaQuality md) + -- Varies with 'hContentType'. + -- + acceptContent :: [Media] -> Appraisal + acceptContent media = Appraisal {vary = [hContentType], score} + where + score req = do + let header = parseMedia (cs $ getContentType req) + + case selectMedia media header of + Just Media{..} -> Suitable quality + Nothing -> UnsupportedMediaType -- | -- Shortcut to accept only form submissions. -- - acceptForm :: Route () + -- @ + -- acceptForm = acceptContent [ \"application/x-www-form-urlencoded\" + -- , \"multipart/form-data\" + -- ] + -- @ + -- + acceptForm :: Appraisal acceptForm = acceptContent [ "application/x-www-form-urlencoded" , "multipart/form-data" ] @@ -399,7 +376,13 @@ where -- | -- Shortcut to accept only JSON documents. -- - acceptJSON :: Route () + -- @ + -- acceptJSON = acceptContent [ \"application/json\" + -- , \"text/json\" + -- ] + -- @ + -- + acceptJSON :: Appraisal acceptJSON = acceptContent [ "application/json" , "text/json" ] @@ -407,76 +390,86 @@ where -- | -- Check that we can send an acceptable response to the client and - -- fail with 'NotAcceptable' if not. Add @Vary: Accept@. + -- fail with 'NotAcceptable' if not. + -- + -- Varies with 'hAccept'. -- - offerContent :: [Media] -> Route () - offerContent media = - vary [hAccept] <* score \req -> - let header = parseMedia (cs $ getAccept req) - in case selectMedia media header of - Nothing -> Unsuitable NotAcceptable "" - Just md -> Suitable (mediaQuality md) + offerContent :: [Media] -> Appraisal + offerContent media = Appraisal {vary = [hAccept], score} + where + score req = do + let header = parseMedia (cs $ getAccept req) + + case selectMedia media header of + Just Media{..} -> Suitable quality + Nothing -> NotAcceptable -- | -- Shortcut to offer HTML replies only. -- - offerHTML :: Route () + -- @ + -- offerHTML = offerContent [\"text/html\"] + -- @ + -- + offerHTML :: Appraisal offerHTML = offerContent ["text/html"] -- | -- Shortcut to offer plain text replies only. -- - offerText :: Route () + -- @ + -- offerText = offerContent [\"text/plain\"] + -- @ + -- + offerText :: Appraisal offerText = offerContent ["text/plain"] -- | -- Shortcut to offer JSON replies only. -- - offerJSON :: Route () - offerJSON = offerContent ["application/json"] - - - -- | - -- Check that we can send an acceptable charset to the client and - -- fail with 'NotAcceptable' if not. Add @Vary: Accept-Charset@. + -- @ + -- offerJSON = offerContent [\"application/json\"] + -- @ -- - offerCharset :: [Media] -> Route () - offerCharset media = - vary [hAcceptCharset] <* score \req -> - let header = parseMedia (cs $ getAcceptCharset req) - in case selectMedia media header of - Nothing -> Unsuitable NotAcceptable "" - Just md -> Suitable (mediaQuality md) + offerJSON :: Appraisal + offerJSON = offerContent ["application/json"] -- | -- Check that we can send an acceptable encoding to the client and - -- fail with 'NotAcceptable' if not. Add @Vary: Accept-Encoding@. + -- fail with 'NotAcceptable' if not. -- - offerEncoding :: [Media] -> Route () - offerEncoding media = - vary [hAcceptEncoding] <* score \req -> - let header = parseMedia (cs $ getAcceptEncoding req) - in case selectMedia media header of - Nothing -> Unsuitable NotAcceptable "" - Just md -> Suitable (mediaQuality md) + -- Varies with 'hAcceptEncoding'. + -- + offerEncoding :: [Media] -> Appraisal + offerEncoding media = Appraisal {vary = [hAcceptEncoding], score} + where + score req = do + let header = parseMedia (cs $ getAcceptEncoding req) + + case selectMedia media header of + Just Media{..} -> Suitable quality + Nothing -> NotAcceptable -- | -- Check that we can send an acceptable language to the client and - -- fail with 'NotAcceptable' if not. Add @Vary: Accept-Language@. + -- fail with 'NotAcceptable' if not. -- - offerLanguage :: [Media] -> Route () - offerLanguage media = - vary [hAcceptLanguage] <* score \req -> - let header = parseMedia (cs $ getAcceptLanguage req) - in case selectMedia media header of - Nothing -> Unsuitable NotAcceptable "" - Just md -> Suitable (mediaQuality md) + -- Varies with 'hAcceptLanguage'. + -- + offerLanguage :: [Media] -> Appraisal + offerLanguage media = Appraisal {vary = [hAcceptLanguage], score} + where + score req = do + let header = parseMedia (cs $ getAcceptLanguage req) + case selectMedia media header of + Just Media{..} -> Suitable quality + Nothing -> NotAcceptable -- Request Utilities ------------------------------------------------------- @@ -495,13 +488,6 @@ where getAccept = fromMaybe "*/*" . getHeader hAccept - -- | - -- Obtain the Accept-Charset header value or the default value of \"*\". - -- - getAcceptCharset :: Request -> ByteString - getAcceptCharset = fromMaybe "*" . getHeader hAcceptCharset - - -- | -- Obtain the Accept-Encoding header value or the default -- value of \"identity,*;q=0\". diff --git a/lib/Hikaru/Types.hs b/lib/Hikaru/Types.hs index 7cb9e8221fd49313e450227a53385c3db5dcb953..c94821330a2b7a17889f7fb024683582fdf34fc9 100644 --- a/lib/Hikaru/Types.hs +++ b/lib/Hikaru/Types.hs @@ -1,36 +1,26 @@ -{-| -Module : Hikaru.Types -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -This module provides types common for multiple other modules. --} +-- | +-- Module : Hikaru.Types +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- module Hikaru.Types ( Param(..) - , RequestError(..) , Severity(..) - , defaultHandler ) where import Praha - import Data.ByteString (ByteString) - import Data.Text (pack, unpack) - import Network.HTTP.Types.Header - import Network.HTTP.Types.Status - import Network.Wai - + import qualified Data.ByteString import qualified Data.ByteString.Lazy import qualified Data.Text.Encoding import qualified Data.Text.Encoding.Error import qualified Data.Text.Lazy - -- | -- Types that can be parsed from a route segment or a query string -- parameter into some kind of value. One does not usually pass around @@ -48,101 +38,101 @@ where {-# INLINE toParam #-} instance Param Int where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Int8 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Int16 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Int32 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Int64 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Word where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Word8 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Word16 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Word32 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Word64 where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Integer where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Natural where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Float where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param Double where - fromParam = readMaybe . unpack + fromParam = readMaybe . cs {-# INLINE fromParam #-} - toParam = pack . show + toParam = cs . show {-# INLINE toParam #-} instance Param () where @@ -175,12 +165,12 @@ where {-# INLINE toParam #-} instance Param Char where - fromParam inp = case (unpack inp) of + fromParam inp = case (cs inp) of [x] -> Just x _else -> Nothing {-# INLINE fromParam #-} - toParam char = pack [char] + toParam char = cs [char] {-# INLINE toParam #-} instance Param String where @@ -228,104 +218,6 @@ where {-# INLINE toParam #-} - -- | - -- Errors used both by "Hikaru.Action" and "Hikaru.Route" - -- to report problems with the requests sent by the user. - -- - -- Order by the severity from the least to the most severe. - -- - data RequestError - = TooManyRequests - -- ^ The user has sent too many requests in a given amount of time. - | PreconditionRequired - -- ^ Server requires the request to be conditional. - | Unprocessable - -- ^ The request was well-formed but had some semantic errors. - | ExpectationFailed - -- ^ Expectation indicated by the Expect header can't be met. - | RangeNotSatisfiable - -- ^ The range specified by the Range header can't be fulfilled. - | UnsupportedMediaType - -- ^ Request payload format is not supported by the server. - | PayloadTooLarge - -- ^ Request entity is larger than limits defined by server. - | PreconditionFailed - -- ^ The client has indicated preconditions in its headers which - -- the server does not meet. - | LengthRequired - -- ^ Content-Length header field is mandatory but missing. - | Gone - -- ^ Requested content has been permanently deleted from server, - -- with no forwarding address. - | Conflict - -- ^ Request conflicts with the current state of the server. - | NotAcceptable - -- ^ After performing content negotiation, no content following - -- the criteria given by the user agent has remained. - | MethodNotAllowed - -- ^ The request method is not available for the resource. - | NotFound - -- ^ Requested resource could not be found. - | Forbidden - -- ^ The client does not have access rights to the content. - -- Unlike 'Unauthorized', the client's identity is known to - -- the server. - | Unauthorized - -- ^ Client must authenticate itself to get the requested response. - | BadRequest - -- ^ The request was not well-formed. - | ServiceUnavailable - -- ^ The server is not ready to handle the request. - | InternalError - -- ^ The server has encountered a situation it doesn't know - -- how to handle. - deriving (Eq, Ord, Show, Typeable) - - -- | - -- Adding two errors chooses the less severe one. - -- - instance Semigroup RequestError where - (<>) = min - {-# INLINE (<>) #-} - - -- | - -- Request errors can be thrown and catched, when accompanied with - -- a short message. - -- - instance Exception (RequestError, Text) - - - -- | - -- Default handlers for when user did not register a custom one. - -- - defaultHandler :: RequestError -> Text -> Application - defaultHandler meth msg _ resp = - case meth of - TooManyRequests -> response status429 "420 Too Many Requests" - PreconditionRequired -> response status428 "428 Precondition Required" - Unprocessable -> response status422 "422 Unprocessable" - ExpectationFailed -> response status417 "417 Expectation Failed" - RangeNotSatisfiable -> response status416 "416 Range Not Satisfiable" - UnsupportedMediaType -> response status415 "415 Unsupported Media Type" - PayloadTooLarge -> response status413 "413 Payload Too Large" - PreconditionFailed -> response status412 "412 Precondition Failed" - LengthRequired -> response status411 "411 Length Required" - Gone -> response status410 "410 Gone" - Conflict -> response status409 "409 Conflict" - NotAcceptable -> response status406 "406 Not Acceptable" - Forbidden -> response status403 "403 Forbidden" - Unauthorized -> response status401 "401 Unauthorized" - BadRequest -> response status400 "400 Bad Request" - ServiceUnavailable -> response status503 "503 Service Unavailable" - InternalError -> response status500 "500 Internal Server Error" - MethodNotAllowed -> response status405 "405 Method Not Allowed" - NotFound -> response status404 "404 Not Found" - - where - response st = resp . responseLBS st [(hContentType, "text/plain")] - . cs . (\str -> if msg == "" then str else msg) - - -- | -- Information severity to be used for messages. -- @@ -333,7 +225,9 @@ where = Success | Warning | Danger - deriving (Eq, Ord, Enum, Show) + deriving (Eq, Ord, Enum, Show, Generic) + + instance NFData Severity -- | -- Concatenation yields the higher severity. diff --git a/lib/Hikaru/Widget.hs b/lib/Hikaru/Widget.hs index 60906b8f4d534e3768eab003233ac2529b13be6c..d3ac9d9bd81d3398a39be837f7fa3450ff7494e8 100644 --- a/lib/Hikaru/Widget.hs +++ b/lib/Hikaru/Widget.hs @@ -148,8 +148,7 @@ where } deriving (Functor, Applicative, Monad, MonadIO, MonadTrans) - instance (MonadAction m) => MonadAction (WidgetT m) where - getActionEnv = lift getActionEnv + instance (MonadAction m) => MonadAction (WidgetT m) -- | diff --git a/test/Hikaru/Demo.hs b/test/Hikaru/Demo.hs index 77c72cf73d42269a6804a7ae4db74b75826e0944..2def3760b77acc88f1a340c9a80e034923a6ff03 100644 --- a/test/Hikaru/Demo.hs +++ b/test/Hikaru/Demo.hs @@ -1,12 +1,12 @@ -{-| -Module : Hikaru.Demo -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) --} +-- | +-- Module : Hikaru.Demo +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- module Hikaru.Demo ( makeDemo @@ -14,9 +14,10 @@ module Hikaru.Demo where import Praha hiding (for_) + import Hikaru + import UnliftIO.MVar import Data.Aeson (Value) - import Hikaru import Lucid import Network.HTTP.Types.Header import Network.HTTP.Types.Status @@ -50,6 +51,8 @@ where , demoModelEnv :: ModelEnv } + type Handler = Action () + -- Model ------------------------------------------------------------------- @@ -101,130 +104,142 @@ where makeDemo :: IO Application makeDemo = do model <- makeModelEnv 0 - cfg <- configFromEnv - return $ makeApplication model cfg + return $ makeApplication model - runAction :: ModelEnv -> Config -> Action () -> Application - runAction me cfg act = do - respond cfg \ae -> do + runAction :: ModelEnv -> Action () -> Application + runAction me act = do + respond \ae -> do runReaderT (unAction act) (DemoEnv ae me) - makeApplication :: ModelEnv -> Config -> Application - makeApplication me cfg = do - dispatch (runAction me cfg) do + makeApplication :: ModelEnv -> Application + makeApplication me = do + dispatch (runAction me) do -- Register nicer 404 error handler. - handler NotFound handleNotFound + handler 404 notFound -- Negotiate content for the root page. - route $ getRootHtmlR <$ get <* offerHTML - route $ getRootTextR <$ get <* offerText + route getRootHtmlR + route getRootTextR + + -- Present a simple greeting page. + route getHelloR - -- Disable caching for these endpoints: - wrapAction (defaultHeader hCacheControl "no-store" >>) do - -- Present a simple greeting page. - route $ getHelloR <$ get </ "hello" <*> arg - <* offerText + -- Present an echoing JSON API. + route postEchoR - -- Present an echoing JSON API. - route $ postEchoR <$ post </ "api" </ "echo" - <* offerJSON <* acceptJSON + -- Handle new cases. + route postCaseR - -- Handle new cases. - route $ postCaseR <$ post </ "case" </ "" - <* acceptForm + -- Handle case listing. + route getCasesR - -- Handle case listing. - route $ getCasesR <$ get </ "case" </ "" - <* offerJSON + -- Routes ------------------------------------------------------------------ - -- Handlers ---------------------------------------------------------------- + getRootHtmlR :: Route '[] Handler + getRootHtmlR = get handle /? offerHTML + where + handle = do + -- Update the counter. + n <- countVisitor - getRootHtmlR :: Action () - getRootHtmlR = do - -- Update the counter. - n <- countVisitor + -- Present fancy HTML result. + sendHTML do + h1_ "Welcome!" + p_ $ "You are " >> toHtml (tshow n) >> ". visitor!" - -- Present fancy HTML result. - sendHTML do - h1_ "Welcome!" - p_ $ "You are " >> toHtml (tshow n) >> ". visitor!" + getRootTextR :: Route '[] Handler + getRootTextR = get handle /? offerText + where + handle = do + -- Update the counter. + n <- countVisitor - getRootTextR :: Action () - getRootTextR = do - -- Update the counter. - n <- countVisitor + -- Present a plain textual result. + sendText $ unlines [ "Welcome!" + , "You are " <> tshow n <> ". visitor!" + ] - -- Present a plain textual result. - sendText $ unlines [ "Welcome!" - , "You are " <> tshow n <> ". visitor!" - ] + postEchoR :: Route '[] Handler + postEchoR = post handle // "api" // "echo" /? offerJSON /? acceptJSON + where + handle = do + setHeader hCacheControl "no-store" + (json :: Value) <- getJSON + sendJSON json - postEchoR :: Action () - postEchoR = do - (json :: Value) <- getJSON - sendJSON json + getHelloR :: Route '[Text] Handler + getHelloR = get handle // "hello" /: "name" /? offerText + where + handle name = do + setHeader hCacheControl "no-store" - getHelloR :: Text -> Action () - getHelloR name = sendText $ "Hello, " <> name <> "!" + when (name == "nobody") do + abort badRequest400 [] "I don't like you." + sendText $ "Hello, " <> name <> "!" - handleNotFound :: RequestError -> Text -> Action () - handleNotFound _exn msg = do + + notFound :: Response -> Action () + notFound _resp = do setStatus status404 - sendHTML do - h1_ "404 Not Found" - p_ (toHtml msg) + sendText $ "See: " <> rhref getRootHtmlR [("q", "404")] + + + postCaseR :: Route '[] Handler + postCaseR = post handle // "case" // "" /? acceptForm + where + handle = do + (res, view) <- postForm "addCase" addCaseForm + + case res of + Nothing -> do + setStatus status400 + sendHTML do + simpleForm_ view + + Just ac -> do + _case <- addCase ac + redirect (rhref getCasesR []) + + getCasesR :: Route '[] Handler + getCasesR = get handle // "case" // "" /? offerHTML + where + handle = do + setHeader hCacheControl "no-store" - postCaseR :: Action () - postCaseR = do - (res, view) <- postForm "addCase" addCaseForm + cases <- liftIO . readMVar . modelCases =<< getModelEnv - case res of - Nothing -> do - setStatus status400 sendHTML do - simpleForm_ view - - Just ac -> do - _case <- addCase ac - redirect "/case/" - - - getCasesR :: Action () - getCasesR = do - cases <- liftIO . readMVar . modelCases =<< getModelEnv - - sendHTML do - h1_ "Cases" - - form_ [method_ "POST"] do - view <- newForm "addCase" Nothing addCaseForm - simpleForm_ view - button_ [type_ "submit"] "Submit" - - table_ do - tr_ do - th_ "Id" - th_ "Name" - th_ "RecNo" - th_ "Mode" - th_ "Active" - - forM cases \Case{..} -> do - tr_ do - td_ $ toHtml $ tshow caseId - td_ $ toHtml $ caseName - td_ $ toHtml $ caseRecNo - td_ $ toHtml $ tshow caseMode - td_ $ toHtml $ tshow caseActive + h1_ "Cases" + + form_ [method_ "POST"] do + view <- newForm "addCase" Nothing addCaseForm + simpleForm_ view + button_ [type_ "submit"] "Submit" + + table_ do + tr_ do + th_ "Id" + th_ "Name" + th_ "RecNo" + th_ "Mode" + th_ "Active" + + forM cases \Case{..} -> do + tr_ do + td_ $ toHtml $ tshow caseId + td_ $ toHtml $ caseName + td_ $ toHtml $ caseRecNo + td_ $ toHtml $ tshow caseMode + td_ $ toHtml $ tshow caseActive -- Forms ------------------------------------------------------------------- diff --git a/test/Hikaru/DemoSpec.hs b/test/Hikaru/DemoSpec.hs index 7294c40e5bc6832368e9641058ab2520f01293a1..431b189399b2f5d12bcce5583b35326e73971598 100644 --- a/test/Hikaru/DemoSpec.hs +++ b/test/Hikaru/DemoSpec.hs @@ -1,14 +1,14 @@ -{-| -Module : Hikaru.DemoSpec -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -Smoke tests coverting a simple demo site. --} +-- | +-- Module : Hikaru.DemoSpec +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- Smoke tests coverting a simple demo site. +-- module Hikaru.DemoSpec ( spec @@ -47,7 +47,7 @@ where runDemo do resp <- get "/404" [] assertStatus 404 resp - assertBodyContains "404" resp + assertBody "See: /?q=404" resp describe "GET /hello/<arg>" do it "greets caller" do @@ -58,6 +58,21 @@ where assertHeader hCacheControl "no-store" resp assertBody "Hello, Tester!" resp + it "greets hacker" do + runDemo do + resp <- get "/hello/Tester%2FHacker" [] + assertStatus 200 resp + assertHeader hContentType "text/plain; charset=utf8" resp + assertHeader hCacheControl "no-store" resp + assertBody "Hello, Tester/Hacker!" resp + + it "fails without a name" do + runDemo do + resp <- get "/hello/nobody" [] + assertStatus 400 resp + assertHeader hContentType "text/plain; charset=utf8" resp + assertBody "I don't like you." resp + describe "POST /api/echo" do it "echoes JSON payload" do runDemo do diff --git a/test/Hikaru/FormSpec.hs b/test/Hikaru/FormSpec.hs index e4cd162b5e8c37dddd3972f678226defe54742e9..79d0bb9fe89a3feb314614e0be3c9429519d89df 100644 --- a/test/Hikaru/FormSpec.hs +++ b/test/Hikaru/FormSpec.hs @@ -1,12 +1,12 @@ -{-| -Module : Hikaru.FormSpec -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) --} +-- | +-- Module : Hikaru.FormSpec +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- module Hikaru.FormSpec ( spec diff --git a/test/Hikaru/Test.hs b/test/Hikaru/Test.hs index ed6ceb4ee3381a00a5a504021d972df3664029f3..04560a29375acecda113847aa6ab4dadba57456b 100644 --- a/test/Hikaru/Test.hs +++ b/test/Hikaru/Test.hs @@ -1,14 +1,14 @@ -{-| -Module : Hikaru.Test -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) - -Common testing definitions. --} +-- | +-- Module : Hikaru.Test +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- +-- Common testing definitions. +-- module Hikaru.Test ( get diff --git a/test/Spec.hs b/test/Spec.hs index c5f149aed5bb95baed47333312c2fd74e95ecbb9..2a00874586afcaa61b7119d0cca36a98c31cbb82 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -1,12 +1,12 @@ -{-| -Module : Spec -Copyright : Jan Hamal Dvořák -License : MIT - -Maintainer : mordae@anilinux.org -Stability : unstable -Portability : non-portable (ghc) --} +-- | +-- Module : Spec +-- Copyright : Jan Hamal Dvořák +-- License : MIT +-- +-- Maintainer : mordae@anilinux.org +-- Stability : unstable +-- Portability : non-portable (ghc) +-- {-# OPTIONS_GHC -F -pgmF hspec-discover #-}