Compare commits
10 Commits
a7836ad08f
...
691e51660f
Author | SHA1 | Date | |
---|---|---|---|
|
691e51660f | ||
|
13e8a11e83 | ||
|
3f76917c40 | ||
|
415b1dc58e | ||
|
77131c4add | ||
|
a9d5d9171a | ||
|
36bd102358 | ||
|
b39e9cefd6 | ||
|
ffec891f26 | ||
|
0808fe130a |
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
WD
|
||||
bin/
|
||||
data
|
||||
/result
|
||||
/Dockerfile
|
||||
/docker-stack.yml
|
||||
dist*
|
||||
*~
|
||||
.env
|
||||
.stack-work/
|
||||
*.swp
|
||||
.direnv*
|
||||
.envrc
|
||||
*/result
|
59
HELPS.cabal
59
HELPS.cabal
@ -1,4 +1,4 @@
|
||||
cabal-version: 1.12
|
||||
cabal-version: 3.0
|
||||
|
||||
name: HELPS
|
||||
version: 0.0.1
|
||||
@ -9,13 +9,29 @@ copyright: James Eversole
|
||||
license: ISC
|
||||
license-file: LICENSE
|
||||
build-type: Simple
|
||||
with-compiler: ghc-9.6.6
|
||||
documentation: True
|
||||
extra-source-files:
|
||||
README
|
||||
|
||||
executable Main
|
||||
main-is: Main.hs
|
||||
hs-source-dirs:
|
||||
src
|
||||
common global
|
||||
build-depends:
|
||||
base
|
||||
, aeson
|
||||
, bytestring
|
||||
, effectful
|
||||
, exceptions
|
||||
, lucid
|
||||
, mtl
|
||||
, postgresql-simple
|
||||
, resource-pool
|
||||
, servant-lucid
|
||||
, servant-server
|
||||
, text
|
||||
, time
|
||||
, utf8-string
|
||||
, wai-middleware-static
|
||||
, warp
|
||||
default-extensions:
|
||||
BlockArguments
|
||||
ConstraintKinds
|
||||
@ -31,25 +47,26 @@ executable Main
|
||||
StrictData
|
||||
TypeFamilies
|
||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N -optl-pthread -fPIC
|
||||
build-depends:
|
||||
base
|
||||
, aeson
|
||||
, bytestring
|
||||
, effectful
|
||||
, exceptions
|
||||
, lucid
|
||||
, mtl
|
||||
, postgresql-simple
|
||||
, resource-pool
|
||||
, servant-server
|
||||
, text
|
||||
, time
|
||||
, utf8-string
|
||||
, warp
|
||||
default-language: GHC2021
|
||||
|
||||
executable Main
|
||||
import: global
|
||||
hs-source-dirs:
|
||||
src
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
Core
|
||||
Database
|
||||
Handlers
|
||||
Logger
|
||||
Routes
|
||||
default-language: GHC2021
|
||||
Utility
|
||||
Views
|
||||
build-depends:
|
||||
HELPS
|
||||
|
||||
library
|
||||
import: global
|
||||
hs-source-dirs:
|
||||
src
|
||||
exposed-modules: Core, Database, Handlers, Logger, Main, Routes, Utility, Views
|
||||
|
27
README.md
27
README.md
@ -8,4 +8,29 @@ A work in progress Nix starting template for web projects utilizing
|
||||
- [PostgreSQL](https://www.postgresql.org/)
|
||||
- [Servant](https://github.com/haskell-servant/servant)
|
||||
|
||||
The repository has a simple CRUD implementation of a "Users" API which demonstrates how to use included effects, create your own effects, and bubble errors to Servant's `ServerError` type.
|
||||
The repository has a simple CRUD implementation of a "Users" API which demonstrates how to use included effects, create your own effects, write/compose HTML using Lucid, and bubble errors to Servant's `ServerError` type.
|
||||
|
||||
How to navigate this codebase (as of revision 77131c4a):
|
||||
|
||||
- Core
|
||||
- Application data types
|
||||
- Effect definitions
|
||||
- Utility function for lifting IO to effectful's IOE
|
||||
- Database
|
||||
- Database effect handler
|
||||
- Table definitons as functions
|
||||
- Helper functions for constructing SQL queries
|
||||
- Handlers
|
||||
- Request handlers in the Eff monad
|
||||
- Logger
|
||||
- Logging implementation using the Database effect for logging to PSQL
|
||||
- Logging implementation to stdout
|
||||
- Main
|
||||
- Application entry point and initialization
|
||||
- Creates a PostgreSQL connection pool to include in the Reader effect
|
||||
- Effect stack runner for real database interactions
|
||||
- Debugging effect stack that mocks database interactions and logs to stdout instead
|
||||
- Routes
|
||||
- Type level route definitions for Servant
|
||||
- Views
|
||||
- Lucid2 HTML templating and composition functions
|
||||
|
24
flake.nix
24
flake.nix
@ -12,24 +12,28 @@
|
||||
packageName = "HELPS";
|
||||
containerPackageName = "${packageName}-container";
|
||||
|
||||
c2n = haskellPackages.callCabal2nix packageName self rec {};
|
||||
HELPS = c2n.overrideAttrs (old: {
|
||||
doHaddock = true;
|
||||
enableSeparateDocOutput = false;
|
||||
enableSharedExecutables = false;
|
||||
enableSharedLibraries = false;
|
||||
});
|
||||
|
||||
haskellPackages = pkgs.haskellPackages;
|
||||
|
||||
enableSharedExecutables = false;
|
||||
enableSharedLibraries = false;
|
||||
|
||||
HELPS = pkgs.haskell.lib.justStaticExecutables self.packages.${system}.default;
|
||||
in {
|
||||
packages.${packageName} =
|
||||
haskellPackages.callCabal2nix packageName self rec {};
|
||||
packages.${packageName} = pkgs.haskell.lib.justStaticExecutables HELPS;
|
||||
packages.HELPSDocs = HELPS;
|
||||
|
||||
packages.default = self.packages.${system}.${packageName};
|
||||
defaultPackage = self.packages.${system}.default;
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
ghcid
|
||||
cabal-install
|
||||
ghc
|
||||
pkgs.haskellPackages.cabal-install
|
||||
pkgs.haskellPackages.ghc
|
||||
pkgs.haskellPackages.ghcid
|
||||
pkgs.haskellPackages.haskell-language-server
|
||||
];
|
||||
inputsFrom = builtins.attrValues self.packages.${system};
|
||||
};
|
||||
|
56
src/Core.hs
56
src/Core.hs
@ -1,7 +1,5 @@
|
||||
module Core where
|
||||
|
||||
import Control.Exception (IOException)
|
||||
import Control.Monad.Catch (catch)
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import Data.Pool (Pool)
|
||||
import qualified Data.Text as T
|
||||
@ -11,42 +9,44 @@ import Database.PostgreSQL.Simple.FromRow (FromRow, field, fromRow)
|
||||
import Database.PostgreSQL.Simple.ToField (ToField, toField)
|
||||
import Database.PostgreSQL.Simple.ToRow (ToRow, toRow)
|
||||
import Effectful
|
||||
import Effectful.Error.Static (Error, throwError)
|
||||
import Effectful.Error.Static (Error)
|
||||
import Effectful.Reader.Static (Reader)
|
||||
import GHC.Generics (Generic)
|
||||
import Servant hiding ((:>), throwError)
|
||||
import Servant hiding ((:>))
|
||||
import Servant.HTML.Lucid
|
||||
|
||||
--
|
||||
-- Core data types
|
||||
--
|
||||
type AppEff = Eff '[ Logger
|
||||
, Database
|
||||
, Reader AppEnv
|
||||
, Error ServerError
|
||||
, IOE
|
||||
]
|
||||
type AppEff =
|
||||
Eff '[ Logger
|
||||
, Database
|
||||
, Reader AppEnv
|
||||
, Error ServerError
|
||||
]
|
||||
|
||||
data AppEnv = AppEnv { pool :: Pool Connection }
|
||||
data AppEnv = AppEnv { pool :: Maybe (Pool Connection) }
|
||||
|
||||
newtype UserId = UserId Int
|
||||
deriving (Show, Generic, FromField, ToField, FromHttpApiData, ToHttpApiData)
|
||||
|
||||
instance ToJSON UserId
|
||||
instance FromJSON UserId
|
||||
instance ToRow UserId
|
||||
instance FromRow UserId
|
||||
|
||||
data User = User { userId :: UserId, userName :: T.Text}
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance FromRow User where
|
||||
fromRow = User <$> field <*> field
|
||||
instance ToRow User where
|
||||
toRow (User uid name) = toRow (uid, name)
|
||||
instance ToJSON UserId
|
||||
instance FromJSON UserId
|
||||
|
||||
instance ToRow UserId
|
||||
instance FromRow UserId
|
||||
|
||||
instance ToJSON User
|
||||
instance FromJSON User
|
||||
|
||||
instance ToRow User where
|
||||
toRow (User uid name) = toRow (uid, name)
|
||||
instance FromRow User where
|
||||
fromRow = User <$> field <*> field
|
||||
|
||||
data Database :: Effect where
|
||||
DatabaseInit
|
||||
:: Database (Eff es) ()
|
||||
@ -57,7 +57,7 @@ data Database :: Effect where
|
||||
DatabaseWrite
|
||||
:: (ToRow a, Show a) => (Query, a) -> Database (Eff es) ()
|
||||
|
||||
data Logger :: Effect where
|
||||
data Logger :: Effect where
|
||||
WriteLog :: LogLevel -> String -> Logger (Eff es) ()
|
||||
|
||||
data LogLevel = Info | Warning | Error
|
||||
@ -65,17 +65,3 @@ data LogLevel = Info | Warning | Error
|
||||
|
||||
instance ToField LogLevel where
|
||||
toField level = toField (T.pack (show level))
|
||||
|
||||
-- Utility
|
||||
liftIOE :: (IOE :> es, Error ServerError :> es) => IO a -> Eff es a
|
||||
-- Lift IO into Eff and catch IOExceptions
|
||||
liftIOE m = liftIO m `catch` \(e::IOException) -> do
|
||||
-- Log IOExceptions to stdout
|
||||
liftIO $ putStrLn $ "ERROR Exception: " ++ show e
|
||||
-- Throw a custom Servant ServerError
|
||||
throwError $ ServerError
|
||||
{ errHTTPCode = 500
|
||||
, errReasonPhrase = "Internal Server Error"
|
||||
, errBody = "This incident will be investigated."
|
||||
, errHeaders = []
|
||||
}
|
||||
|
173
src/Database.hs
173
src/Database.hs
@ -1,20 +1,23 @@
|
||||
module Database where
|
||||
|
||||
import Core
|
||||
import Utility
|
||||
|
||||
import Control.Exception (IOException)
|
||||
import Data.Aeson (ToJSON)
|
||||
import Data.Maybe (listToMaybe)
|
||||
import Control.Exception (IOException)
|
||||
import Data.Aeson (ToJSON)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Maybe (listToMaybe)
|
||||
import Data.Pool
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text as T
|
||||
import Data.Time (getCurrentTime, UTCTime)
|
||||
import Database.PostgreSQL.Simple
|
||||
import Database.PostgreSQL.Simple.ToField (ToField)
|
||||
import Effectful
|
||||
import Effectful.Dispatch.Dynamic
|
||||
import Effectful.Error.Static (Error)
|
||||
import Effectful.Error.Static (Error)
|
||||
import Effectful.Reader.Static
|
||||
import Effectful.State.Static.Local (State, get, put, evalState)
|
||||
import Servant hiding ((:>), throwError)
|
||||
import Effectful.State.Static.Local (State, get, put, evalState)
|
||||
import Servant hiding ((:>), throwError)
|
||||
|
||||
type instance DispatchOf Database = 'Dynamic
|
||||
|
||||
@ -23,25 +26,73 @@ type DatabaseExeEffects es = ( Reader AppEnv :> es
|
||||
, IOE :> es
|
||||
)
|
||||
|
||||
databaseInit
|
||||
:: (Database :> es, Error ServerError :> es) => Eff es ()
|
||||
databaseInit :: (Database :> es, Error ServerError :> es)
|
||||
=> Eff es ()
|
||||
databaseInit = send DatabaseInit
|
||||
|
||||
databaseRead
|
||||
:: (ToField a, Show a, Database :> es, Error ServerError :> es)
|
||||
=> (Query, a) -> Eff es [User]
|
||||
databaseRead :: (ToField a, Show a, Database :> es, Error ServerError :> es)
|
||||
=> (Query, a) -> Eff es [User]
|
||||
databaseRead = send . DatabaseRead
|
||||
|
||||
databaseRead_
|
||||
:: (Database :> es, Error ServerError :> es) => Query -> Eff es [User]
|
||||
databaseRead_ :: (Database :> es, Error ServerError :> es)
|
||||
=> Query -> Eff es [User]
|
||||
databaseRead_ = send . DatabaseRead_
|
||||
|
||||
databaseWrite
|
||||
:: (ToRow a, Show a, Database :> es, Error ServerError :> es)
|
||||
=> (Query, a) -> Eff es ()
|
||||
databaseWrite :: (ToRow a, Show a, Database :> es, Error ServerError :> es)
|
||||
=> (Query, a) -> Eff es ()
|
||||
databaseWrite = send . DatabaseWrite
|
||||
|
||||
runDatabaseDebug :: DatabaseExeEffects es => Eff (Database : es) a -> Eff es a
|
||||
runDatabaseIO :: DatabaseExeEffects es
|
||||
=> Eff (Database : es) a -> Eff es a
|
||||
runDatabaseIO = interpret $ \_ -> \case
|
||||
DatabaseInit -> do
|
||||
env <- ask
|
||||
case pool env of
|
||||
Just pool -> liftWithPool pool $ \conn -> do
|
||||
execute_ conn createUsersTable
|
||||
execute_ conn createLogsTable
|
||||
pure ()
|
||||
Nothing -> do
|
||||
databaseEscapeLog Error
|
||||
"No database pool in scope; did you mean to run the mock handler?"
|
||||
throwDefaultISE
|
||||
|
||||
DatabaseRead (statement, values) -> do
|
||||
env <- ask
|
||||
case pool env of
|
||||
Just pool ->
|
||||
liftWithPool pool $ \conn -> query conn statement (Only values)
|
||||
Nothing -> do
|
||||
databaseEscapeLog Error
|
||||
"No database pool in scope; did you mean to run the mock handler?"
|
||||
throwDefaultISE
|
||||
|
||||
DatabaseRead_ statement -> do
|
||||
env <- ask
|
||||
case pool env of
|
||||
Just pool -> liftWithPool pool $ \conn -> query_ conn statement
|
||||
Nothing -> do
|
||||
databaseEscapeLog Error
|
||||
"No database pool in scope; did you mean to run the mock handler?"
|
||||
throwDefaultISE
|
||||
|
||||
DatabaseWrite (statement, values) -> do
|
||||
env <- ask
|
||||
case pool env of
|
||||
Just pool -> liftWithPool pool $ \conn -> do
|
||||
execute conn statement values
|
||||
pure ()
|
||||
Nothing -> do
|
||||
databaseEscapeLog Error
|
||||
"No database pool in scope; did you mean to run the mock handler?"
|
||||
throwDefaultISE
|
||||
where
|
||||
liftWithPool :: (IOE :> es, Error ServerError :> es)
|
||||
=> (Pool Connection) -> (Connection -> IO a) -> Eff es a
|
||||
liftWithPool p f = liftIOE $ withResource p f
|
||||
|
||||
runDatabaseDebug :: DatabaseExeEffects es
|
||||
=> Eff (Database : es) a -> Eff es a
|
||||
runDatabaseDebug = interpret $ \_ -> \case
|
||||
DatabaseInit -> do
|
||||
liftIOE $ putStrLn "Mocked setup of the database"
|
||||
@ -49,7 +100,7 @@ runDatabaseDebug = interpret $ \_ -> \case
|
||||
liftIOE $ putStrLn
|
||||
$ "Mocked a READ database operation with statement:\n"
|
||||
++ show statement
|
||||
++ " and values:\n"
|
||||
++ "\nValues:\n"
|
||||
++ show values
|
||||
pure []
|
||||
DatabaseRead_ statement -> do
|
||||
@ -57,48 +108,14 @@ runDatabaseDebug = interpret $ \_ -> \case
|
||||
pure []
|
||||
DatabaseWrite (statement, values) -> do
|
||||
liftIOE $ putStrLn
|
||||
$ "Mocked a WRITE database operation with a user named " ++ show values
|
||||
$ "Mocked a WRITE database operation with statement:\n"
|
||||
++ show statement
|
||||
++ "\nValues:\n"
|
||||
++ show values
|
||||
|
||||
runDatabaseIO :: DatabaseExeEffects es => Eff (Database : es) a -> Eff es a
|
||||
runDatabaseIO = interpret $ \_ -> \case
|
||||
DatabaseInit -> init
|
||||
DatabaseRead (statement, values) -> read statement values
|
||||
DatabaseRead_ statement -> read_ statement
|
||||
DatabaseWrite (statement, values) -> write statement values
|
||||
where
|
||||
init :: DatabaseExeEffects es => Eff es ()
|
||||
init = do
|
||||
AppEnv { pool } <- ask
|
||||
liftWithPool pool $ \conn -> do
|
||||
execute_ conn tableUsers
|
||||
execute_ conn tableLogs
|
||||
pure ()
|
||||
|
||||
read :: (ToField a, FromRow b, DatabaseExeEffects es)
|
||||
=> Query -> a -> Eff es [b]
|
||||
read statement values = do
|
||||
AppEnv { pool } <- ask
|
||||
liftWithPool pool $ \conn ->
|
||||
query conn statement (Only values)
|
||||
|
||||
read_ :: (FromRow b, DatabaseExeEffects es) => Query -> Eff es [b]
|
||||
read_ statement = do
|
||||
AppEnv { pool } <- ask
|
||||
liftWithPool pool $ \conn ->
|
||||
query_ conn statement
|
||||
|
||||
write :: (DatabaseExeEffects es, ToRow a) => Query -> a -> Eff es ()
|
||||
write statement values = do
|
||||
AppEnv { pool } <- ask
|
||||
liftWithPool pool $ \conn -> do
|
||||
execute conn statement values
|
||||
pure ()
|
||||
|
||||
liftWithPool p f = liftIOE $ withResource p f
|
||||
|
||||
createConnectionPool :: IO (Pool Connection)
|
||||
createConnectionPool = newPool $ defaultPoolConfig
|
||||
(connectPostgreSQL "host=localhost dbname=demo")
|
||||
createConnectionPool :: ByteString -> IO (Pool Connection)
|
||||
createConnectionPool connectString = newPool $ defaultPoolConfig
|
||||
(connectPostgreSQL connectString)
|
||||
close
|
||||
60
|
||||
10
|
||||
@ -112,18 +129,26 @@ writeUser name = ("INSERT INTO users (name) VALUES (?);", Only name)
|
||||
deleteUser :: UserId -> (Query, UserId)
|
||||
deleteUser userId = ("DELETE FROM users WHERE id = ?;", userId)
|
||||
|
||||
tableUsers :: Query
|
||||
tableUsers = "CREATE TABLE IF NOT EXISTS users ( \
|
||||
\id integer NOT NULL, \
|
||||
\name character varying(255) NOT NULL \
|
||||
\);"
|
||||
createUsersTable :: Query
|
||||
createUsersTable = "CREATE TABLE IF NOT EXISTS users ( \
|
||||
\ id SERIAL PRIMARY KEY, \
|
||||
\ name character varying(255) NOT NULL \
|
||||
\);"
|
||||
|
||||
tableLogs :: Query
|
||||
tableLogs = "CREATE TABLE IF NOT EXISTS logs ( \
|
||||
\ id SERIAL PRIMARY KEY, \
|
||||
\ level VARCHAR(10) NOT NULL, \
|
||||
\ message TEXT NOT NULL, \
|
||||
\ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||||
\ source VARCHAR(100), \
|
||||
\ context JSONB \
|
||||
\);"
|
||||
createLogsTable :: Query
|
||||
createLogsTable = "CREATE TABLE IF NOT EXISTS logs ( \
|
||||
\ id SERIAL PRIMARY KEY, \
|
||||
\ level VARCHAR(10) NOT NULL, \
|
||||
\ message TEXT NOT NULL, \
|
||||
\ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||||
\ source VARCHAR(100), \
|
||||
\ context JSONB \
|
||||
\);"
|
||||
|
||||
databaseEscapeLog :: (IOE :> es, Error ServerError :> es) => LogLevel -> String -> Eff es ()
|
||||
databaseEscapeLog level msg = do
|
||||
time <- liftIOE getCurrentTime
|
||||
liftIOE $ putStrLn
|
||||
$ "TIMESTAMP: " ++ show time ++ "\n"
|
||||
++ "LEVEL: " ++ show level ++ "\n"
|
||||
++ "MESSAGE: " ++ msg
|
||||
|
@ -3,35 +3,49 @@ module Handlers where
|
||||
import Core
|
||||
import Database
|
||||
import Logger
|
||||
import Views as V
|
||||
|
||||
import qualified Data.ByteString.Char8 as C
|
||||
import Data.List
|
||||
import qualified Data.Text as T
|
||||
import Effectful
|
||||
import Effectful.Error.Static (Error, throwError)
|
||||
import Lucid (Html)
|
||||
import Servant hiding ((:>), throwError)
|
||||
import qualified Servant as S
|
||||
import Servant.HTML.Lucid
|
||||
|
||||
-- Type synonym for common CRUD constraints like interacting with a database,
|
||||
-- logging, and the possibility to throw an error.
|
||||
type CRUD es = ( Database :> es, Logger :> es, Error ServerError :> es )
|
||||
|
||||
rootHandler :: (Logger :> es, Error ServerError :> es) => Eff es T.Text
|
||||
rootHandler = (writeLog Info "Hit the root!") >>= \_ ->
|
||||
return "Hello, World!"
|
||||
-- Remember to drop effect constraints you don't need!
|
||||
rootHandler :: (Logger :> es, Error ServerError :> es)
|
||||
=> Eff es (Html ())
|
||||
rootHandler = (writeLog Info "Hit the root!")
|
||||
>>= \_ -> return V.root
|
||||
|
||||
userListHandler :: CRUD es => Eff es [User]
|
||||
userListHandler = (writeLog Info "Selected all users!") >>= \_ ->
|
||||
databaseRead_ "SELECT id, name FROM users"
|
||||
userListHandler :: CRUD es
|
||||
=> Eff es (Html ())
|
||||
userListHandler = do
|
||||
users <- databaseRead_ "SELECT id, name FROM users"
|
||||
writeLog Info "Selected all users!"
|
||||
return $ V.baseDoc $ case users of
|
||||
[] -> warning "No users found"
|
||||
_ -> foldMap userHtml users
|
||||
|
||||
userGetHandler :: CRUD es => UserId -> Eff es User
|
||||
userGetHandler :: CRUD es
|
||||
=> UserId -> Eff es User
|
||||
userGetHandler userId = databaseRead (queryUser userId) >>= \mUser ->
|
||||
case mUser of
|
||||
(a:_) -> pure a
|
||||
[] -> pure (User (UserId 0) "No user found")
|
||||
|
||||
userPostHandler :: CRUD es => T.Text -> Eff es NoContent
|
||||
userPostHandler :: CRUD es
|
||||
=> T.Text -> Eff es NoContent
|
||||
userPostHandler name =
|
||||
databaseWrite (writeUser name) >>= \_ -> return NoContent
|
||||
|
||||
userDeleteHandler :: CRUD es => UserId -> Eff es NoContent
|
||||
userDeleteHandler :: CRUD es
|
||||
=> UserId -> Eff es NoContent
|
||||
userDeleteHandler userId =
|
||||
databaseWrite (deleteUser userId) >>= \_ -> return NoContent
|
||||
|
@ -2,14 +2,15 @@ module Logger where
|
||||
|
||||
import Core
|
||||
import Database
|
||||
import Utility
|
||||
|
||||
import Data.Time (getCurrentTime, UTCTime)
|
||||
import Effectful
|
||||
import Effectful.Dispatch.Dynamic
|
||||
import Effectful.Error.Static (Error, throwError)
|
||||
import Effectful.Reader.Static
|
||||
import GHC.Generics (Generic)
|
||||
import Servant hiding ((:>))
|
||||
import GHC.Generics (Generic)
|
||||
import Servant hiding ((:>))
|
||||
|
||||
type LogToDatabase es = (Database :> es, Error ServerError :> es, IOE :> es)
|
||||
|
||||
@ -19,29 +20,19 @@ writeLog :: (Logger :> es, Error ServerError :> es)
|
||||
=> LogLevel -> String -> Eff es ()
|
||||
writeLog level msg = send (WriteLog level msg)
|
||||
|
||||
runLoggerConsole :: (Error ServerError :> es, IOE :> es) =>
|
||||
Eff (Logger : es) a -> Eff es a
|
||||
runLoggerConsole :: (Error ServerError :> es, IOE :> es)
|
||||
=> Eff (Logger : es) a -> Eff es a
|
||||
runLoggerConsole = interpret $ \_ -> \case
|
||||
WriteLog level msg -> do
|
||||
time <- liftIOE getCurrentTime
|
||||
time <- liftIOE getCurrentTime
|
||||
liftIOE $ putStrLn
|
||||
$ "TIMESTAMP: " ++ show time ++ "\n"
|
||||
++ "LEVEL: " ++ show level ++ "\n"
|
||||
++ "MESSAGE: " ++ msg
|
||||
|
||||
runLoggerPSQL :: LogToDatabase es => Eff (Logger : es) a -> Eff es a
|
||||
runLoggerPSQL :: LogToDatabase es
|
||||
=> Eff (Logger : es) a -> Eff es a
|
||||
runLoggerPSQL = interpret $ \_ -> \case
|
||||
WriteLog level msg ->
|
||||
databaseWrite
|
||||
("INSERT INTO logs (level, message) VALUES (?,?);", (level, msg))
|
||||
|
||||
{-
|
||||
CREATE TABLE logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
level VARCHAR(10) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source VARCHAR(100),
|
||||
context JSONB
|
||||
);
|
||||
-}
|
||||
|
98
src/Main.hs
98
src/Main.hs
@ -6,48 +6,84 @@ import Handlers
|
||||
import Logger
|
||||
import Routes
|
||||
|
||||
import Control.Monad.Except (ExceptT (ExceptT))
|
||||
import Control.Monad.Except (ExceptT (ExceptT))
|
||||
import Data.List
|
||||
import Effectful
|
||||
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
|
||||
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
|
||||
import Effectful.Reader.Static
|
||||
import Network.Wai.Handler.Warp (run)
|
||||
import Servant hiding ((:>), throwError)
|
||||
import qualified Servant as S
|
||||
import Network.Wai.Handler.Warp (run)
|
||||
import Network.Wai.Middleware.Static ((>->), staticPolicy, noDots, addBase)
|
||||
import Servant hiding ((:>), throwError, inject)
|
||||
import qualified Servant as S
|
||||
import System.Environment (lookupEnv)
|
||||
|
||||
--
|
||||
-- Types
|
||||
--
|
||||
|
||||
data RunMode = Production | Debug
|
||||
|
||||
proxy :: Proxy AppAPI
|
||||
proxy = Proxy
|
||||
|
||||
--
|
||||
-- Application
|
||||
--
|
||||
main :: IO ()
|
||||
main = do
|
||||
pool <- createConnectionPool
|
||||
let env = AppEnv { pool = pool }
|
||||
runEffStack env $ databaseInit
|
||||
run port . serve proxy $ app env
|
||||
(envPort, envMode) <- do
|
||||
port <- lookupEnv "PORT"
|
||||
env <- lookupEnv "ENVIRONMENT"
|
||||
pure (port, env)
|
||||
|
||||
app :: AppEnv -> Server AppAPI
|
||||
app env = transformEff env
|
||||
$ rootHandler
|
||||
:<|> userListHandler
|
||||
:<|> userGetHandler
|
||||
:<|> userPostHandler
|
||||
:<|> userDeleteHandler
|
||||
let port = maybe 8080 read envPort
|
||||
mode = case envMode of
|
||||
Just "production" -> Production
|
||||
_ -> Debug
|
||||
|
||||
transformEff :: AppEnv -> ServerT AppAPI AppEff -> ServerT AppAPI Handler
|
||||
transformEff env = hoistServer proxy
|
||||
$ Handler
|
||||
. ExceptT
|
||||
. runEffStack env
|
||||
env <- case mode of
|
||||
Production -> do
|
||||
dbPool <- createConnectionPool "host=localhost dbname=demo"
|
||||
let env = AppEnv { pool = Just dbPool }
|
||||
appEff env databaseInit
|
||||
pure env
|
||||
Debug -> do
|
||||
let env = AppEnv { pool = Nothing }
|
||||
appDebug env databaseInit
|
||||
pure env
|
||||
|
||||
runEffStack :: AppEnv -> AppEff a -> IO (Either ServerError a)
|
||||
runEffStack env = runEff
|
||||
. runErrorNoCallStack
|
||||
. runReader env
|
||||
. runDatabaseIO
|
||||
. runLoggerPSQL
|
||||
run port . middleware . serve proxy $ app env mode
|
||||
|
||||
port :: Int
|
||||
port = 8080
|
||||
app :: AppEnv -> RunMode -> Server AppAPI
|
||||
app env Production = hoistServer proxy (Handler . ExceptT . appEff env) handlers
|
||||
app env Debug = hoistServer proxy (Handler . ExceptT . appDebug env) handlers
|
||||
|
||||
proxy :: Proxy AppAPI
|
||||
proxy = Proxy
|
||||
handlers :: ServerT AppAPI AppEff
|
||||
handlers = rootHandler
|
||||
:<|> userListHandler
|
||||
:<|> userGetHandler
|
||||
:<|> userPostHandler
|
||||
:<|> userDeleteHandler
|
||||
|
||||
middleware :: Application -> Application
|
||||
middleware = staticPolicy (noDots >-> addBase "data/assets/public")
|
||||
|
||||
--
|
||||
-- Effect Stacks
|
||||
--
|
||||
|
||||
appEff :: AppEnv -> AppEff a -> IO (Either ServerError a)
|
||||
appEff env = runEff
|
||||
. runErrorNoCallStack
|
||||
. runReader env
|
||||
. runDatabaseIO
|
||||
. runLoggerPSQL
|
||||
. inject
|
||||
|
||||
appDebug :: AppEnv -> AppEff a -> IO (Either ServerError a)
|
||||
appDebug env = runEff
|
||||
. runErrorNoCallStack
|
||||
. runReader env
|
||||
. runDatabaseDebug
|
||||
. runLoggerConsole
|
||||
. inject
|
||||
|
@ -3,15 +3,17 @@ module Routes where
|
||||
import Core
|
||||
|
||||
import qualified Data.Text as T
|
||||
import Lucid (Html)
|
||||
import Servant
|
||||
import Servant.HTML.Lucid
|
||||
|
||||
--
|
||||
-- Routes
|
||||
--
|
||||
type Root = Get '[PlainText] T.Text
|
||||
type Root = Get '[HTML] (Html ())
|
||||
|
||||
type UserList = "user"
|
||||
:> Get '[JSON] [User]
|
||||
:> Get '[HTML] (Html ())
|
||||
|
||||
type UserGet = "user"
|
||||
:> Capture "userId" UserId
|
||||
|
31
src/Utility.hs
Normal file
31
src/Utility.hs
Normal file
@ -0,0 +1,31 @@
|
||||
module Utility where
|
||||
|
||||
import qualified Views as V
|
||||
|
||||
import Control.Exception (IOException)
|
||||
import Control.Monad.Catch (catch)
|
||||
import Effectful
|
||||
import Effectful.Error.Static (Error, throwError)
|
||||
import Lucid (renderBS)
|
||||
import Servant ( ServerError(..)
|
||||
, errHTTPCode
|
||||
, errReasonPhrase
|
||||
, errBody
|
||||
, errHeaders
|
||||
)
|
||||
|
||||
liftIOE :: (IOE :> es, Error ServerError :> es) => IO a -> Eff es a
|
||||
-- Lift IO into Eff and catch IOExceptions
|
||||
liftIOE m = liftIO m `catch` \(e::IOException) -> do
|
||||
-- Log IOExceptions to stdout
|
||||
liftIO $ putStrLn $ "ERROR Exception: " ++ show e
|
||||
-- Throw a custom Servant ServerError
|
||||
throwDefaultISE
|
||||
|
||||
throwDefaultISE :: (IOE :> es, Error ServerError :> es) => Eff es a
|
||||
throwDefaultISE = throwError $ ServerError
|
||||
{ errHTTPCode = 500
|
||||
, errReasonPhrase = "Internal Server Error"
|
||||
, errBody = renderBS V.internalServerError
|
||||
, errHeaders = []
|
||||
}
|
41
src/Views.hs
Normal file
41
src/Views.hs
Normal file
@ -0,0 +1,41 @@
|
||||
module Views where
|
||||
|
||||
import Core
|
||||
|
||||
import Data.Text
|
||||
import Data.String (fromString)
|
||||
import Effectful
|
||||
import Lucid
|
||||
|
||||
baseDoc :: Html () -> Html ()
|
||||
baseDoc a = doctypehtml_ $ do
|
||||
head_ $ do
|
||||
meta_ [ name_ "viewport", content_ "width=device-width, initial-scale=1.0" ]
|
||||
title_ "HELPS Template!"
|
||||
link_ [rel_ "stylesheet", type_ "text/css", href_ "/style.css"]
|
||||
body_ $ do
|
||||
main_ [ class_ "container" ] a
|
||||
|
||||
root :: Html ()
|
||||
root = baseDoc $ do
|
||||
h1_ "Welcome to HELPS!"
|
||||
p_ "Haskell, Effectful, Lucid, PostgreSQL, Servant"
|
||||
p_ "You can get started by reviewing the README.md for directions on using \
|
||||
\ this template for your own projects."
|
||||
|
||||
userHtml :: User -> Html ()
|
||||
userHtml user = div_ [] $ do
|
||||
ul_ $ do
|
||||
li_ $ do
|
||||
"Username: " >> toHtml (show $ userName user)
|
||||
ul_ $ li_ $ "User ID: " >> toHtml (show $ userId user)
|
||||
|
||||
warning :: String -> Html ()
|
||||
warning s = p_ [class_ "warning"] (toHtml s)
|
||||
|
||||
internalServerError :: Html ()
|
||||
internalServerError = baseDoc $ do
|
||||
div_ [ style_ "text-align: center; margin: 3% 0 0 0;" ] $ do
|
||||
h1_ "500 INTERNAL SERVER ERROR"
|
||||
p_ "This issue is probably our fault. \
|
||||
\ Please try again shortly or contact us for help."
|
Loading…
x
Reference in New Issue
Block a user