diff --git a/CHANGELOG.md b/CHANGELOG.md index 9315bc9..e0d83dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3.0] - 2020-10-14 +### Added +- `servant` support. + ## [0.1.2.3] - 2020-08-17 ### Added - `defaultHandleLog400` middleware to log response bodies of 4xx and 5xx responses. diff --git a/app/ServantApp.hs b/app/ServantApp.hs new file mode 100644 index 0000000..92fd5d5 --- /dev/null +++ b/app/ServantApp.hs @@ -0,0 +1,42 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeOperators #-} + +import Data.Aeson (encode) +import Data.OpenApi (OpenApi) +import Data.Proxy (Proxy (..)) +import Data.Text (Text) +import Servant (Description, Get, Handler, JSON, PlainText, Post, ReqBody, Summary, + (:<|>) (..), (:>)) +import Servant.OpenAPI.UI (OpenApiSchemaUI, openapiSchemaUIServer) +import Servant.OpenApi (toOpenApi) + +import Web.Template.Servant (CbdAuth, UserId (..), Version, runServantServer) + +type API = Version "1" :> + ( Summary "ping route" :> Description "Returns pong" :> "ping" :> Get '[PlainText] Text + :<|> CbdAuth :> + ( Summary "hello route" :> Description "Returns hello + user id" :> "hello" :> Get '[PlainText] Text + :<|> "post" :> ReqBody '[JSON] Int :> Post '[JSON] Text + ) + ) + +pingH :: Handler Text +pingH = return "pong!" + +helloH :: UserId -> Handler Text +helloH (UserId userId) = return $ "Hello " <> userId + +postH :: UserId -> Int -> Handler Text +postH _ _ = return "Foo" + +swagger :: OpenApi +swagger = toOpenApi @API Proxy + +main :: IO () +main = do + print $ encode swagger + + runServantServer @(OpenApiSchemaUI "swagger-ui" "swagger.json" :<|> API) 5000 + $ openapiSchemaUIServer swagger :<|> (pingH :<|> (\userId -> helloH userId :<|> postH userId)) diff --git a/cabal.project b/cabal.project new file mode 100644 index 0000000..c83efdf --- /dev/null +++ b/cabal.project @@ -0,0 +1,24 @@ +packages: *.cabal + +package web-template + ghc-options: -Wall + +source-repository-package + type: git + location: https://github.com/maksbotan/servant-swagger-ui + tag: 0e3a6c3dcc54e081ca499e0ed0a73b9a12b538a8 + subdir: servant-swagger-ui-core + +source-repository-package + type: git + location: https://github.com/maksbotan/servant-swagger-ui + tag: 0e3a6c3dcc54e081ca499e0ed0a73b9a12b538a8 + subdir: servant-swagger-ui + +source-repository-package + type: git + location: https://github.com/maksbotan/servant-swagger-ui + tag: 0e3a6c3dcc54e081ca499e0ed0a73b9a12b538a8 + subdir: servant-openapi-ui + +allow-newer: servant-blaze:servant diff --git a/src/Web/Template.hs b/src/Web/Template.hs index 3f54c37..b8d5253 100644 --- a/src/Web/Template.hs +++ b/src/Web/Template.hs @@ -5,6 +5,6 @@ module Web.Template , module Web.Template.Types ) where -import Web.Template.Except -import Web.Template.Server -import Web.Template.Types +import Web.Template.Except +import Web.Template.Server +import Web.Template.Types diff --git a/src/Web/Template/Except.hs b/src/Web/Template/Except.hs index b36ff3a..371c97f 100644 --- a/src/Web/Template/Except.hs +++ b/src/Web/Template/Except.hs @@ -13,14 +13,11 @@ module Web.Template.Except , MonadWebError(..) ) where -import Data.Aeson (FromJSON (..), ToJSON (..), - defaultOptions, genericToEncoding) -import Data.String (fromString) -import GHC.Generics (Generic) -import Network.HTTP.Types.Status (Status, status403, status404, - status500) -import Web.Scotty.Trans (ActionT, ScottyError (..), json, - raise, status) +import Data.Aeson (FromJSON (..), ToJSON (..), defaultOptions, genericToEncoding) +import Data.String (fromString) +import GHC.Generics (Generic) +import Network.HTTP.Types.Status (Status, status403, status404, status500) +import Web.Scotty.Trans (ActionT, ScottyError (..), json, raise, status) instance ScottyError Except where @@ -36,7 +33,8 @@ data Except deriving instance Show Except -newtype JsonWebError = JsonWebError { error :: String } +newtype JsonWebError + = JsonWebError { error :: String } deriving (Generic) instance ToJSON JsonWebError where @@ -73,6 +71,6 @@ class MonadWebError m where throwJson500 :: (Show e, ToJSON e) => e -> m a throwJson500 = throwJson status500 -instance Monad m => MonadWebError (ActionT Except m) where +instance {-# OVERLAPPING #-} Monad m => MonadWebError (ActionT Except m) where {-# INLINE throwJson #-} throwJson s e = raise $ CustomJsonException s e diff --git a/src/Web/Template/Servant.hs b/src/Web/Template/Servant.hs new file mode 100644 index 0000000..a082343 --- /dev/null +++ b/src/Web/Template/Servant.hs @@ -0,0 +1,67 @@ +module Web.Template.Servant + ( runServantServer + , runServantServerWith + , runServantServerWithContext + + , OpenApiSchemaUI + , openapiSchemaUIServer + + , module Web.Template.Servant.Aeson + , module Web.Template.Servant.API + , module Web.Template.Servant.Auth + , module Web.Template.Servant.Error + ) where + +import Data.Proxy (Proxy (..)) +import Network.Wai (Application) +import Network.Wai.Handler.Warp (Settings, runSettings) +import Servant.OpenAPI.UI (OpenApiSchemaUI, openapiSchemaUIServer) +import Servant.Server (Context, DefaultErrorFormatters, ErrorFormatters, HasContextEntry, + HasServer, Server, serveWithContext, type (.++), (.++)) + +import Web.Template.Types (Port) +import Web.Template.Wai (defaultHandleLog, defaultHeaderCORS, warpSettings) + +import Web.Template.Servant.API +import Web.Template.Servant.Aeson +import Web.Template.Servant.Auth +import Web.Template.Servant.Error + +runServantServer + :: forall api + . (HasServer api '[ErrorFormatters]) + => Port + -> Server api + -> IO () +runServantServer = runServantServerWith @api id (defaultHeaderCORS . defaultHandleLog) + +runServantServerWith + :: forall api + . (HasServer api '[ErrorFormatters]) + => (Settings -> Settings) + -> (Application -> Application) + -- ^ Middlewares + -> Port + -> Server api + -> IO () +runServantServerWith userSettings middlewares port server = + runSettings (warpSettings port userSettings) + $ middlewares + $ serveWithContext @api Proxy cbdContext + $ server + +runServantServerWithContext + :: forall api ctx + . (HasServer api (ctx .++ '[ErrorFormatters]), HasContextEntry ((ctx .++ '[ErrorFormatters]) .++ DefaultErrorFormatters) ErrorFormatters) + => (Settings -> Settings) + -> (Application -> Application) + -- ^ Middlewares + -> Port + -> Context ctx + -> Server api + -> IO () +runServantServerWithContext userSettings middlewares port ctx server = + runSettings (warpSettings port userSettings) + $ middlewares + $ serveWithContext @api Proxy (ctx .++ cbdContext) + $ server diff --git a/src/Web/Template/Servant/API.hs b/src/Web/Template/Servant/API.hs new file mode 100644 index 0000000..f438c43 --- /dev/null +++ b/src/Web/Template/Servant/API.hs @@ -0,0 +1,64 @@ +module Web.Template.Servant.API where + +import Control.Lens ((?~)) +import Data.Function ((&)) +import Data.OpenApi (applyTags, description) +import Data.Proxy (Proxy (..)) +import Data.String (fromString) +import Data.Text (pack) +import GHC.TypeLits (AppendSymbol, KnownSymbol, Symbol, symbolVal) + +import Control.Monad.IO.Class (MonadIO (..)) +import Control.Monad.Trans.Resource (runResourceT) +import Network.Wai (Request, Response, ResponseReceived) +import Servant (HasServer (..), Raw, (:>)) +import Servant.OpenApi (HasOpenApi (..)) +import Servant.Server.Internal (RouteResult (..), Router' (..), runDelayed, runHandler) + +-- | Prepend version to every sub-route. +-- +-- > type API = Version "3" :> ("route1" :<|> "route2") +type Version (v :: Symbol) = AppendSymbol "v" v + +-- | Mark sub-api with a Swagger tag with description. +-- +-- > type API +-- > = (Tag "foo" "Some Foo routes" :> ("foo1" :<|> "foo2")) +-- > :<|> (Tag "bar" "Some Bar routes" :> ("bar1" :<|> "bar2")) +data Tag (tag :: Symbol) (descr :: Symbol) + +instance HasServer api context => HasServer (Tag tag descr :> api) context where + type ServerT (Tag tag descr :> api) m = ServerT api m + + route _ = route @api Proxy + hoistServerWithContext _ = hoistServerWithContext @api Proxy + +instance (KnownSymbol tag, KnownSymbol descr, HasOpenApi api) => HasOpenApi (Tag tag descr :> api) where + toOpenApi _ = toOpenApi @api Proxy + & applyTags [fromString (symbolVal @tag Proxy) & description ?~ pack (symbolVal @descr Proxy)] + +-- | As 'Raw', but with access to the custom monad @m@. +-- +-- See . +data RawM + +instance HasServer RawM ctx where + type ServerT RawM m = Request -> (Response -> IO ResponseReceived) -> m ResponseReceived + + hoistServerWithContext _ _ nt m = \request respond -> nt $ m request respond + + route _ _ dApp = RawRouter $ \env request respond -> runResourceT $ do + r <- runDelayed dApp env request + liftIO $ case r of + Fail a -> respond $ Fail a + FailFatal e -> respond $ FailFatal e + Route appH -> do + r' <- runHandler $ appH request (respond . Route) + -- appH may return result with 'Right' _only_ by calling smth like @liftIO . respond@, + -- so in case of 'Left' we may suppose that 'respond' was never called. + case r' of + Left e -> respond $ FailFatal e + Right x -> return x + +instance HasOpenApi RawM where + toOpenApi _ = toOpenApi @Raw Proxy diff --git a/src/Web/Template/Servant/Aeson.hs b/src/Web/Template/Servant/Aeson.hs new file mode 100644 index 0000000..1c7a764 --- /dev/null +++ b/src/Web/Template/Servant/Aeson.hs @@ -0,0 +1,71 @@ +module Web.Template.Servant.Aeson where + +import Control.Lens ((?~)) +import Data.Aeson +import Data.Aeson.Casing +import Data.Functor ((<&>)) +import Data.Proxy (Proxy (..)) +import GHC.Generics + +import Data.OpenApi +import Data.OpenApi.Internal.Schema + +-- | This wrapper is intended to be used with @DerivingVia@ to make +-- consistent 'ToJSON', 'FromJSON' and 'ToSchema' for some data type. +-- +-- Usage: +-- +-- > data Foo +-- > = Foo +-- > { fFoo :: String +-- > , fBar :: String +-- > } +-- > deriving (Eq, Show, Generic) +-- > deriving (ToJSON, FromJSON, ToSchema) via CamelCaseAeson Foo +-- +-- Instances are made with 'aesonPrefix' 'camelCase' and 'omitNothingFields' set to @True@. +newtype CamelCaseAeson a + = CamelCaseAeson a + +prefixOptions :: Options +prefixOptions = (aesonPrefix camelCase) { omitNothingFields = True } + +instance (Generic a, GToJSON Zero (Rep a), GToEncoding Zero (Rep a)) => ToJSON (CamelCaseAeson a) where + toJSON (CamelCaseAeson a) = genericToJSON prefixOptions a + toEncoding (CamelCaseAeson a) = genericToEncoding prefixOptions a + +instance (Generic a, GFromJSON Zero (Rep a)) => FromJSON (CamelCaseAeson a) where + parseJSON = fmap CamelCaseAeson . genericParseJSON prefixOptions + +instance (Generic a, GToSchema (Rep a)) => ToSchema (CamelCaseAeson a) where + declareNamedSchema _ = + genericDeclareNamedSchema @a (fromAesonOptions prefixOptions) Proxy + +-- | This wrapper extends 'ToSchema' instance of the underlying type with +-- an example obtained from 'WithExample' instance. +-- +-- Usage: +-- +-- > data Foo +-- > = ... +-- > deriving (Eq, Show, Generic) +-- > deriving (ToJSON, FromJSON) via CamelCaseAeson Foo +-- > deriving (ToSchema) via SwaggerWithExample (CamelCaseAeson Foo) +-- +-- Last line reuses 'ToSchema' instances from 'CamelCaseAeson' to ensure that instances +-- stay consistent. +newtype SwaggerWithExample a + = SwaggerWithExample a + +-- | Provide an example for Swagger schema. +-- +-- Swagger supports only one example per named schema. +class ToJSON a => WithExample a where + mkExample :: a + +instance (WithExample a, ToSchema a) => ToSchema (SwaggerWithExample a) where + declareNamedSchema _ = declareNamedSchema @a Proxy + <&> schema . example ?~ toJSON (mkExample @a) + +instance (ToJSON (CamelCaseAeson a), WithExample a) => WithExample (CamelCaseAeson a) where + mkExample = CamelCaseAeson $ mkExample @a diff --git a/src/Web/Template/Servant/Auth.hs b/src/Web/Template/Servant/Auth.hs new file mode 100644 index 0000000..58227f0 --- /dev/null +++ b/src/Web/Template/Servant/Auth.hs @@ -0,0 +1,66 @@ +module Web.Template.Servant.Auth + where + +-- after https://www.stackage.org/haddock/lts-15.15/servant-server-0.16.2/src/Servant.Server.Experimental.Auth.html + +import Control.Lens (at, (.~), (?~)) +import Data.Function ((&)) +import Data.Functor ((<&>)) +import Data.Proxy (Proxy (..)) +import Data.Text (Text) +import GHC.Generics (Generic) + +import Data.OpenApi.Internal (ApiKeyLocation (..), ApiKeyParams (..), SecurityRequirement (..), + SecurityScheme (..), SecuritySchemeType (..)) +import Data.OpenApi.Lens (components, description, security, securitySchemes) +import Data.OpenApi.Operation (allOperations, setResponse) +import Network.HTTP.Types.Header (hContentType) +import Network.Wai (requestHeaders) +import Servant.API ((:>)) +import Servant.OpenApi (HasOpenApi (..)) +import Servant.Server (HasServer (..), ServerError (..), err401) +import Servant.Server.Internal (addAuthCheck, delayedFailFatal, withRequest) +import Web.Cookie (parseCookiesText) + +-- | Add authenthication via @id@ Cookie. +-- +-- Usage: +-- +-- > type API = CbdAuth :> (....) +-- +-- Handlers will get an 'UserId' argument. +data CbdAuth + +newtype UserId + = UserId { getUserId :: Text } + deriving (Eq, Show, Generic) + +instance HasServer api context => HasServer (CbdAuth :> api) context where + type ServerT (CbdAuth :> api) m = UserId -> ServerT api m + + hoistServerWithContext _ pc nt s = hoistServerWithContext @api Proxy pc nt . s + + route _ context sub = + route @api Proxy context + $ addAuthCheck sub + $ withRequest $ \req -> + maybe (delayedFailFatal err) return $ + lookup "cookie" (requestHeaders req) + <&> parseCookiesText + >>= lookup "id" + <&> UserId + where + err = err401 + { errBody = "{\"error\": \"Authorization failed\"}" + , errHeaders = [(hContentType, "application/json")] + } + +instance HasOpenApi api => HasOpenApi (CbdAuth :> api) where + toOpenApi _ = toOpenApi @api Proxy + & components . securitySchemes . at "cbdCookie" ?~ idCookie + & allOperations . security .~ [SecurityRequirement $ mempty & at "cbdCookie" ?~ []] + & setResponse 401 (return $ mempty & description .~ "Authorization failed") + where + idCookie = SecurityScheme + (SecuritySchemeApiKey (ApiKeyParams "id" ApiKeyCookie)) + (Just "`id` cookie") diff --git a/src/Web/Template/Servant/Error.hs b/src/Web/Template/Servant/Error.hs new file mode 100644 index 0000000..e5d379c --- /dev/null +++ b/src/Web/Template/Servant/Error.hs @@ -0,0 +1,26 @@ +module Web.Template.Servant.Error + ( cbdContext + ) where + +import Data.Aeson (pairs, (.=)) +import Data.Aeson.Encoding (encodingToLazyByteString) +import Network.HTTP.Types.Header (hContentType) +import Servant (Context (..), err400) +import Servant.Server (ErrorFormatter, ErrorFormatters (..), ServerError (..), + defaultErrorFormatters) + +cbdContext :: Context '[ErrorFormatters] +cbdContext = cbdErrorFormatters :. EmptyContext + +cbdErrorFormatters :: ErrorFormatters +cbdErrorFormatters = defaultErrorFormatters + { bodyParserErrorFormatter = fmtError + , urlParseErrorFormatter = fmtError + , headerParseErrorFormatter = fmtError + } + +fmtError :: ErrorFormatter +fmtError _ _ msg = err400 + { errBody = encodingToLazyByteString $ pairs $ "error" .= msg + , errHeaders = [(hContentType, "application/json")] + } diff --git a/src/Web/Template/Servant/Error/Instance.hs b/src/Web/Template/Servant/Error/Instance.hs new file mode 100644 index 0000000..d860357 --- /dev/null +++ b/src/Web/Template/Servant/Error/Instance.hs @@ -0,0 +1,21 @@ +{-# OPTIONS_GHC -Wno-orphans #-} + +module Web.Template.Servant.Error.Instance where + +import Control.Monad.Except (MonadError (..)) +import Data.Aeson (encode) +import Data.ByteString.Char8 (unpack) +import Network.HTTP.Types.Header (hContentType) +import Network.HTTP.Types.Status (Status (..)) +import Servant.Server (ServerError (..)) + +import Web.Template.Except (MonadWebError (..)) + +instance {-# OVERLAPPING #-} MonadError ServerError m => MonadWebError m where + throwJson (Status code reason) e = throwError $ ServerError + { errHTTPCode = code + , errReasonPhrase = unpack reason + , errBody = encode e + , errHeaders = [(hContentType, "application/json")] + } + diff --git a/src/Web/Template/Server.hs b/src/Web/Template/Server.hs index 99097a9..c210092 100644 --- a/src/Web/Template/Server.hs +++ b/src/Web/Template/Server.hs @@ -11,32 +11,26 @@ module Web.Template.Server , defaultHandleLog , defaultHandleLog400 , defaultHeaderCORS + , defaultOnException , toApplication ) where -import Control.Concurrent (threadDelay) -import Control.Exception (AsyncException (..), SomeException (..), catch, - fromException) -import Control.Monad (unless) -import Control.Monad.RWS (RWST, evalRWST) -import Data.String (fromString) -import Data.Text.Encoding (encodeUtf8) -import Data.Text.Lazy as TL (Text, toStrict) -import Network.HTTP.Types.Header (Header) -import Network.HTTP.Types.Status (status401) -import Network.Wai (Application, Middleware, Request, mapResponseHeaders, - modifyResponse) -import Network.Wai.Handler.Warp (InvalidRequest (..), Settings, defaultSettings, - exceptionResponseForDebug, setOnException, - setOnExceptionResponse, setPort) -import System.BCD.Log (error') -import Web.Cookie (parseCookiesText) -import Web.Scotty.Trans (Options (..), ScottyT, defaultHandler, header, json, - middleware, next, param, scottyAppT, scottyOptsT, - status) -import Web.Template.Except (Except, JsonWebError (..), handleEx) -import Web.Template.Log (bcdlog, bcdlog400) -import Web.Template.Types +import Control.Concurrent (threadDelay) +import Control.Exception (AsyncException (..), SomeException (..), catch, fromException) +import Control.Monad (unless) +import Control.Monad.RWS (RWST, evalRWST) +import Data.String (fromString) +import Data.Text.Encoding (encodeUtf8) +import Data.Text.Lazy as TL (Text, toStrict) +import Network.HTTP.Types.Status (status401) +import Network.Wai (Application) +import Network.Wai.Handler.Warp (Settings) +import Web.Cookie (parseCookiesText) +import Web.Scotty.Trans (Options (..), ScottyT, defaultHandler, header, json, middleware, + next, param, scottyAppT, scottyOptsT, status) +import Web.Template.Except (Except, JsonWebError (..), handleEx) +import Web.Template.Types +import Web.Template.Wai -- | Restart `f` on `error` after `1s`. restartOnError1 :: IO () -> IO () @@ -85,22 +79,6 @@ toScottyT CustomWebServer {..} = do evalCustomWebServer :: Monad m => CustomWebServer r w s -> RWST r w s m b1 -> m b1 evalCustomWebServer CustomWebServer {..} = (fst <$>) . (\rws -> evalRWST rws readerEnv stateEnv) -defaultHandleLog :: Middleware -defaultHandleLog = bcdlog - --- | Log everything as 'defaultHandleLog' and also log response bodies for --- 4xx and 5xx responses. -defaultHandleLog400 :: Middleware -defaultHandleLog400 = bcdlog400 - -defaultHeaderCORS :: Middleware -defaultHeaderCORS = modifyResponse (mapResponseHeaders addHeaderCORS) - where - addHeaderCORS :: [Header] -> [Header] - addHeaderCORS headers = case lookup "Access-Control-Allow-Origin" headers of - Just _ -> headers - Nothing -> ("Access-Control-Allow-Origin", "*") : headers - runRoute :: Monoid w => Route r w s -> ScottyM r w s () runRoute Route {..} = method (fromString $ "/:version" <> path) (checkVersion version . auth $ process) @@ -108,22 +86,7 @@ runRoute Route {..} = method (fromString $ "/:version" <> path) (checkVersion ve -- If no timeout is given, it will be set to Warp's default (30 seconds). -- scottyOpts :: Port -> (Settings -> Settings) -> Options -scottyOpts port userSettings = Options 1 warpSettings - where - warpSettings = - setOnException onException - . setOnExceptionResponse exceptionResponseForDebug - . setPort port - . userSettings - $ defaultSettings - -onException :: Maybe Request -> SomeException -> IO () -onException _ e = - case fromException e of - -- This exception happens too often when using Chrome, thus we better ignore it. - -- See https://github.com/yesodweb/wai/issues/421 - Just ConnectionClosedByPeer -> return () - _ -> error' ("scotty" :: Text) $ show e +scottyOpts port userSettings = Options 1 $ warpSettings port userSettings auth :: Monoid w => Process r w s -> WebM r w s () auth (Process p) = p diff --git a/src/Web/Template/Types.hs b/src/Web/Template/Types.hs index 144ab7c..2513ff6 100644 --- a/src/Web/Template/Types.hs +++ b/src/Web/Template/Types.hs @@ -17,11 +17,11 @@ module Web.Template.Types , CustomWebServerRW, CustomWebServerRS, CustomWebServerWS ) where -import Control.Monad.RWS (RWST (..)) -import Data.Text as T (Text) -import Network.Wai (Middleware) -import Web.Scotty.Trans (ActionT, RoutePattern, ScottyT) -import Web.Template.Except (Except) +import Control.Monad.RWS (RWST (..)) +import Data.Text as T (Text) +import Network.Wai (Middleware) +import Web.Scotty.Trans (ActionT, RoutePattern, ScottyT) +import Web.Template.Except (Except) -- | Alias for UserId. type UserId = T.Text @@ -40,8 +40,9 @@ type ScottyM r w s a = ScottyT Except (Env r w s) a -- | 'Process' encapsulates what we what to do inside 'Route'. -- If your need to check authorization then use 'AuthProcess' constructor. -data Process r w s = Process (WebM r w s ()) - | AuthProcess (UserId -> WebM r w s ()) +data Process r w s + = Process (WebM r w s ()) + | AuthProcess (UserId -> WebM r w s ()) -- | 'Route' include every needed information to make some stuff with request. It includes: -- * environment @env@ that we can store and use (for example, connections for databases); @@ -49,19 +50,23 @@ data Process r w s = Process (WebM r w s ()) -- * version of path (it should be like `/v{Integer}/`); -- * path (just name of path); -- * process (what should we do with request). -data Route r w s = Route { method :: RoutePattern -> WebM r w s () -> ScottyT Except (Env r w s) () - , version :: Int - , path :: String - , process :: Process r w s - } +data Route r w s + = Route + { method :: RoutePattern -> WebM r w s () -> ScottyT Except (Env r w s) () + , version :: Int + , path :: String + , process :: Process r w s + } -- | Contains environment and processing routes. -data CustomWebServer r w s = CustomWebServer { readerEnv :: r - , writerEnv :: w - , stateEnv :: s - , middlewares :: [Middleware] - , routes :: [Route r w s] - } +data CustomWebServer r w s + = CustomWebServer + { readerEnv :: r + , writerEnv :: w + , stateEnv :: s + , middlewares :: [Middleware] + , routes :: [Route r w s] + } ----------------------------------------------------------------------------------------------------- -- DEFAULT TYPES -- diff --git a/src/Web/Template/Wai.hs b/src/Web/Template/Wai.hs new file mode 100644 index 0000000..c958c83 --- /dev/null +++ b/src/Web/Template/Wai.hs @@ -0,0 +1,46 @@ +module Web.Template.Wai + where + +import Control.Exception (SomeException, fromException) +import Data.Text (Text) +import Network.HTTP.Types (Header) +import Network.Wai (Middleware, Request, mapResponseHeaders, modifyResponse) +import Network.Wai.Handler.Warp (InvalidRequest (..), Port, Settings, defaultSettings, + exceptionResponseForDebug, setOnException, setOnExceptionResponse, + setPort) + +import System.BCD.Log (error') + +import Web.Template.Log (bcdlog, bcdlog400) + +defaultHandleLog :: Middleware +defaultHandleLog = bcdlog + +-- | Log everything as 'defaultHandleLog' and also log response bodies for +-- 4xx and 5xx responses. +defaultHandleLog400 :: Middleware +defaultHandleLog400 = bcdlog400 + +defaultHeaderCORS :: Middleware +defaultHeaderCORS = modifyResponse (mapResponseHeaders addHeaderCORS) + where + addHeaderCORS :: [Header] -> [Header] + addHeaderCORS headers = case lookup "Access-Control-Allow-Origin" headers of + Just _ -> headers + Nothing -> ("Access-Control-Allow-Origin", "*") : headers + +defaultOnException :: Maybe Request -> SomeException -> IO () +defaultOnException _ e = + case fromException e of + -- This exception happens too often when using Chrome, thus we better ignore it. + -- See https://github.com/yesodweb/wai/issues/421 + Just ConnectionClosedByPeer -> return () + _ -> error' ("scotty" :: Text) $ show e + +warpSettings :: Port -> (Settings -> Settings) -> Settings +warpSettings port userSettings = + setOnException defaultOnException + . setOnExceptionResponse exceptionResponseForDebug + . setPort port + . userSettings + $ defaultSettings diff --git a/web-template.cabal b/web-template.cabal index 36f61c1..ca84fe0 100644 --- a/web-template.cabal +++ b/web-template.cabal @@ -1,5 +1,5 @@ name: web-template -version: 0.1.2.3 +version: 0.1.3.0 synopsis: Web template description: Web template includes: @@ -21,27 +21,57 @@ cabal-version: >=1.10 library hs-source-dirs: src exposed-modules: Web.Template + , Web.Template.Wai + + , Web.Template.Servant + , Web.Template.Servant.API + , Web.Template.Servant.Aeson + , Web.Template.Servant.Auth + , Web.Template.Servant.Error + , Web.Template.Servant.Error.Instance other-modules: Web.Template.Except , Web.Template.Server , Web.Template.Types , Web.Template.Log + build-depends: base >= 4.7 && < 5 , aeson - , bytestring + , aeson-casing , bcd-log + , bytestring , cookie , data-default , fast-logger , http-types + , lens , mtl + , openapi3 + , resourcet , scotty + , servant >= 0.18 + , servant-openapi-ui >= 0.3.4 + , servant-openapi3 + , servant-server >= 0.18 , text + , time , wai , wai-extra , wai-logger , warp - , time default-language: Haskell2010 + default-extensions: AllowAmbiguousTypes + , DataKinds + , DeriveGeneric + , FlexibleContexts + , FlexibleInstances + , KindSignatures + , MultiParamTypeClasses + , OverloadedStrings + , ScopedTypeVariables + , TypeApplications + , TypeFamilies + , TypeOperators + , UndecidableInstances executable web-template hs-source-dirs: app @@ -54,6 +84,22 @@ executable web-template , scotty default-language: Haskell2010 +executable web-template-servant + hs-source-dirs: app + main-is: ServantApp.hs + ghc-options: -threaded -rtsopts + build-depends: base + , aeson + , openapi3 + , servant-openapi-ui + , servant-openapi3 + , servant-server + , text + , wai + , warp + , web-template + default-language: Haskell2010 + source-repository head type: git location: https://github.com/biocad/web-template