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
|
name: HELPS
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
@ -9,13 +9,29 @@ copyright: James Eversole
|
|||||||
license: ISC
|
license: ISC
|
||||||
license-file: LICENSE
|
license-file: LICENSE
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
|
with-compiler: ghc-9.6.6
|
||||||
|
documentation: True
|
||||||
extra-source-files:
|
extra-source-files:
|
||||||
README
|
README
|
||||||
|
|
||||||
executable Main
|
common global
|
||||||
main-is: Main.hs
|
build-depends:
|
||||||
hs-source-dirs:
|
base
|
||||||
src
|
, aeson
|
||||||
|
, bytestring
|
||||||
|
, effectful
|
||||||
|
, exceptions
|
||||||
|
, lucid
|
||||||
|
, mtl
|
||||||
|
, postgresql-simple
|
||||||
|
, resource-pool
|
||||||
|
, servant-lucid
|
||||||
|
, servant-server
|
||||||
|
, text
|
||||||
|
, time
|
||||||
|
, utf8-string
|
||||||
|
, wai-middleware-static
|
||||||
|
, warp
|
||||||
default-extensions:
|
default-extensions:
|
||||||
BlockArguments
|
BlockArguments
|
||||||
ConstraintKinds
|
ConstraintKinds
|
||||||
@ -31,25 +47,26 @@ executable Main
|
|||||||
StrictData
|
StrictData
|
||||||
TypeFamilies
|
TypeFamilies
|
||||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N -optl-pthread -fPIC
|
ghc-options: -threaded -rtsopts -with-rtsopts=-N -optl-pthread -fPIC
|
||||||
build-depends:
|
default-language: GHC2021
|
||||||
base
|
|
||||||
, aeson
|
executable Main
|
||||||
, bytestring
|
import: global
|
||||||
, effectful
|
hs-source-dirs:
|
||||||
, exceptions
|
src
|
||||||
, lucid
|
main-is: Main.hs
|
||||||
, mtl
|
|
||||||
, postgresql-simple
|
|
||||||
, resource-pool
|
|
||||||
, servant-server
|
|
||||||
, text
|
|
||||||
, time
|
|
||||||
, utf8-string
|
|
||||||
, warp
|
|
||||||
other-modules:
|
other-modules:
|
||||||
Core
|
Core
|
||||||
Database
|
Database
|
||||||
Handlers
|
Handlers
|
||||||
Logger
|
Logger
|
||||||
Routes
|
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/)
|
- [PostgreSQL](https://www.postgresql.org/)
|
||||||
- [Servant](https://github.com/haskell-servant/servant)
|
- [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";
|
packageName = "HELPS";
|
||||||
containerPackageName = "${packageName}-container";
|
containerPackageName = "${packageName}-container";
|
||||||
|
|
||||||
|
c2n = haskellPackages.callCabal2nix packageName self rec {};
|
||||||
|
HELPS = c2n.overrideAttrs (old: {
|
||||||
|
doHaddock = true;
|
||||||
|
enableSeparateDocOutput = false;
|
||||||
|
enableSharedExecutables = false;
|
||||||
|
enableSharedLibraries = false;
|
||||||
|
});
|
||||||
|
|
||||||
haskellPackages = pkgs.haskellPackages;
|
haskellPackages = pkgs.haskellPackages;
|
||||||
|
|
||||||
enableSharedExecutables = false;
|
|
||||||
enableSharedLibraries = false;
|
|
||||||
|
|
||||||
HELPS = pkgs.haskell.lib.justStaticExecutables self.packages.${system}.default;
|
|
||||||
in {
|
in {
|
||||||
packages.${packageName} =
|
packages.${packageName} = pkgs.haskell.lib.justStaticExecutables HELPS;
|
||||||
haskellPackages.callCabal2nix packageName self rec {};
|
packages.HELPSDocs = HELPS;
|
||||||
|
|
||||||
packages.default = self.packages.${system}.${packageName};
|
packages.default = self.packages.${system}.${packageName};
|
||||||
defaultPackage = self.packages.${system}.default;
|
defaultPackage = self.packages.${system}.default;
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
ghcid
|
pkgs.haskellPackages.cabal-install
|
||||||
cabal-install
|
pkgs.haskellPackages.ghc
|
||||||
ghc
|
pkgs.haskellPackages.ghcid
|
||||||
|
pkgs.haskellPackages.haskell-language-server
|
||||||
];
|
];
|
||||||
inputsFrom = builtins.attrValues self.packages.${system};
|
inputsFrom = builtins.attrValues self.packages.${system};
|
||||||
};
|
};
|
||||||
|
56
src/Core.hs
56
src/Core.hs
@ -1,7 +1,5 @@
|
|||||||
module Core where
|
module Core where
|
||||||
|
|
||||||
import Control.Exception (IOException)
|
|
||||||
import Control.Monad.Catch (catch)
|
|
||||||
import Data.Aeson (FromJSON, ToJSON)
|
import Data.Aeson (FromJSON, ToJSON)
|
||||||
import Data.Pool (Pool)
|
import Data.Pool (Pool)
|
||||||
import qualified Data.Text as T
|
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.ToField (ToField, toField)
|
||||||
import Database.PostgreSQL.Simple.ToRow (ToRow, toRow)
|
import Database.PostgreSQL.Simple.ToRow (ToRow, toRow)
|
||||||
import Effectful
|
import Effectful
|
||||||
import Effectful.Error.Static (Error, throwError)
|
import Effectful.Error.Static (Error)
|
||||||
import Effectful.Reader.Static (Reader)
|
import Effectful.Reader.Static (Reader)
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
import Servant hiding ((:>), throwError)
|
import Servant hiding ((:>))
|
||||||
|
import Servant.HTML.Lucid
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Core data types
|
-- Core data types
|
||||||
--
|
--
|
||||||
type AppEff = Eff '[ Logger
|
type AppEff =
|
||||||
, Database
|
Eff '[ Logger
|
||||||
, Reader AppEnv
|
, Database
|
||||||
, Error ServerError
|
, Reader AppEnv
|
||||||
, IOE
|
, Error ServerError
|
||||||
]
|
]
|
||||||
|
|
||||||
data AppEnv = AppEnv { pool :: Pool Connection }
|
data AppEnv = AppEnv { pool :: Maybe (Pool Connection) }
|
||||||
|
|
||||||
newtype UserId = UserId Int
|
newtype UserId = UserId Int
|
||||||
deriving (Show, Generic, FromField, ToField, FromHttpApiData, ToHttpApiData)
|
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}
|
data User = User { userId :: UserId, userName :: T.Text}
|
||||||
deriving (Show, Generic)
|
deriving (Show, Generic)
|
||||||
|
|
||||||
instance FromRow User where
|
instance ToJSON UserId
|
||||||
fromRow = User <$> field <*> field
|
instance FromJSON UserId
|
||||||
instance ToRow User where
|
|
||||||
toRow (User uid name) = toRow (uid, name)
|
instance ToRow UserId
|
||||||
|
instance FromRow UserId
|
||||||
|
|
||||||
instance ToJSON User
|
instance ToJSON User
|
||||||
instance FromJSON 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
|
data Database :: Effect where
|
||||||
DatabaseInit
|
DatabaseInit
|
||||||
:: Database (Eff es) ()
|
:: Database (Eff es) ()
|
||||||
@ -57,7 +57,7 @@ data Database :: Effect where
|
|||||||
DatabaseWrite
|
DatabaseWrite
|
||||||
:: (ToRow a, Show a) => (Query, a) -> Database (Eff es) ()
|
:: (ToRow a, Show a) => (Query, a) -> Database (Eff es) ()
|
||||||
|
|
||||||
data Logger :: Effect where
|
data Logger :: Effect where
|
||||||
WriteLog :: LogLevel -> String -> Logger (Eff es) ()
|
WriteLog :: LogLevel -> String -> Logger (Eff es) ()
|
||||||
|
|
||||||
data LogLevel = Info | Warning | Error
|
data LogLevel = Info | Warning | Error
|
||||||
@ -65,17 +65,3 @@ data LogLevel = Info | Warning | Error
|
|||||||
|
|
||||||
instance ToField LogLevel where
|
instance ToField LogLevel where
|
||||||
toField level = toField (T.pack (show level))
|
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
|
module Database where
|
||||||
|
|
||||||
import Core
|
import Core
|
||||||
|
import Utility
|
||||||
|
|
||||||
import Control.Exception (IOException)
|
import Control.Exception (IOException)
|
||||||
import Data.Aeson (ToJSON)
|
import Data.Aeson (ToJSON)
|
||||||
import Data.Maybe (listToMaybe)
|
import Data.ByteString (ByteString)
|
||||||
|
import Data.Maybe (listToMaybe)
|
||||||
import Data.Pool
|
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
|
||||||
import Database.PostgreSQL.Simple.ToField (ToField)
|
import Database.PostgreSQL.Simple.ToField (ToField)
|
||||||
import Effectful
|
import Effectful
|
||||||
import Effectful.Dispatch.Dynamic
|
import Effectful.Dispatch.Dynamic
|
||||||
import Effectful.Error.Static (Error)
|
import Effectful.Error.Static (Error)
|
||||||
import Effectful.Reader.Static
|
import Effectful.Reader.Static
|
||||||
import Effectful.State.Static.Local (State, get, put, evalState)
|
import Effectful.State.Static.Local (State, get, put, evalState)
|
||||||
import Servant hiding ((:>), throwError)
|
import Servant hiding ((:>), throwError)
|
||||||
|
|
||||||
type instance DispatchOf Database = 'Dynamic
|
type instance DispatchOf Database = 'Dynamic
|
||||||
|
|
||||||
@ -23,25 +26,73 @@ type DatabaseExeEffects es = ( Reader AppEnv :> es
|
|||||||
, IOE :> es
|
, IOE :> es
|
||||||
)
|
)
|
||||||
|
|
||||||
databaseInit
|
databaseInit :: (Database :> es, Error ServerError :> es)
|
||||||
:: (Database :> es, Error ServerError :> es) => Eff es ()
|
=> Eff es ()
|
||||||
databaseInit = send DatabaseInit
|
databaseInit = send DatabaseInit
|
||||||
|
|
||||||
databaseRead
|
databaseRead :: (ToField a, Show a, Database :> es, Error ServerError :> es)
|
||||||
:: (ToField a, Show a, Database :> es, Error ServerError :> es)
|
=> (Query, a) -> Eff es [User]
|
||||||
=> (Query, a) -> Eff es [User]
|
|
||||||
databaseRead = send . DatabaseRead
|
databaseRead = send . DatabaseRead
|
||||||
|
|
||||||
databaseRead_
|
databaseRead_ :: (Database :> es, Error ServerError :> es)
|
||||||
:: (Database :> es, Error ServerError :> es) => Query -> Eff es [User]
|
=> Query -> Eff es [User]
|
||||||
databaseRead_ = send . DatabaseRead_
|
databaseRead_ = send . DatabaseRead_
|
||||||
|
|
||||||
databaseWrite
|
databaseWrite :: (ToRow a, Show a, Database :> es, Error ServerError :> es)
|
||||||
:: (ToRow a, Show a, Database :> es, Error ServerError :> es)
|
=> (Query, a) -> Eff es ()
|
||||||
=> (Query, a) -> Eff es ()
|
|
||||||
databaseWrite = send . DatabaseWrite
|
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
|
runDatabaseDebug = interpret $ \_ -> \case
|
||||||
DatabaseInit -> do
|
DatabaseInit -> do
|
||||||
liftIOE $ putStrLn "Mocked setup of the database"
|
liftIOE $ putStrLn "Mocked setup of the database"
|
||||||
@ -49,7 +100,7 @@ runDatabaseDebug = interpret $ \_ -> \case
|
|||||||
liftIOE $ putStrLn
|
liftIOE $ putStrLn
|
||||||
$ "Mocked a READ database operation with statement:\n"
|
$ "Mocked a READ database operation with statement:\n"
|
||||||
++ show statement
|
++ show statement
|
||||||
++ " and values:\n"
|
++ "\nValues:\n"
|
||||||
++ show values
|
++ show values
|
||||||
pure []
|
pure []
|
||||||
DatabaseRead_ statement -> do
|
DatabaseRead_ statement -> do
|
||||||
@ -57,48 +108,14 @@ runDatabaseDebug = interpret $ \_ -> \case
|
|||||||
pure []
|
pure []
|
||||||
DatabaseWrite (statement, values) -> do
|
DatabaseWrite (statement, values) -> do
|
||||||
liftIOE $ putStrLn
|
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
|
createConnectionPool :: ByteString -> IO (Pool Connection)
|
||||||
runDatabaseIO = interpret $ \_ -> \case
|
createConnectionPool connectString = newPool $ defaultPoolConfig
|
||||||
DatabaseInit -> init
|
(connectPostgreSQL connectString)
|
||||||
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")
|
|
||||||
close
|
close
|
||||||
60
|
60
|
||||||
10
|
10
|
||||||
@ -112,18 +129,26 @@ writeUser name = ("INSERT INTO users (name) VALUES (?);", Only name)
|
|||||||
deleteUser :: UserId -> (Query, UserId)
|
deleteUser :: UserId -> (Query, UserId)
|
||||||
deleteUser userId = ("DELETE FROM users WHERE id = ?;", userId)
|
deleteUser userId = ("DELETE FROM users WHERE id = ?;", userId)
|
||||||
|
|
||||||
tableUsers :: Query
|
createUsersTable :: Query
|
||||||
tableUsers = "CREATE TABLE IF NOT EXISTS users ( \
|
createUsersTable = "CREATE TABLE IF NOT EXISTS users ( \
|
||||||
\id integer NOT NULL, \
|
\ id SERIAL PRIMARY KEY, \
|
||||||
\name character varying(255) NOT NULL \
|
\ name character varying(255) NOT NULL \
|
||||||
\);"
|
\);"
|
||||||
|
|
||||||
tableLogs :: Query
|
createLogsTable :: Query
|
||||||
tableLogs = "CREATE TABLE IF NOT EXISTS logs ( \
|
createLogsTable = "CREATE TABLE IF NOT EXISTS logs ( \
|
||||||
\ id SERIAL PRIMARY KEY, \
|
\ id SERIAL PRIMARY KEY, \
|
||||||
\ level VARCHAR(10) NOT NULL, \
|
\ level VARCHAR(10) NOT NULL, \
|
||||||
\ message TEXT NOT NULL, \
|
\ message TEXT NOT NULL, \
|
||||||
\ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
\ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||||||
\ source VARCHAR(100), \
|
\ source VARCHAR(100), \
|
||||||
\ context JSONB \
|
\ 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 Core
|
||||||
import Database
|
import Database
|
||||||
import Logger
|
import Logger
|
||||||
|
import Views as V
|
||||||
|
|
||||||
import qualified Data.ByteString.Char8 as C
|
import qualified Data.ByteString.Char8 as C
|
||||||
import Data.List
|
import Data.List
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Effectful
|
import Effectful
|
||||||
import Effectful.Error.Static (Error, throwError)
|
import Effectful.Error.Static (Error, throwError)
|
||||||
|
import Lucid (Html)
|
||||||
import Servant hiding ((:>), throwError)
|
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 )
|
type CRUD es = ( Database :> es, Logger :> es, Error ServerError :> es )
|
||||||
|
|
||||||
rootHandler :: (Logger :> es, Error ServerError :> es) => Eff es T.Text
|
-- Remember to drop effect constraints you don't need!
|
||||||
rootHandler = (writeLog Info "Hit the root!") >>= \_ ->
|
rootHandler :: (Logger :> es, Error ServerError :> es)
|
||||||
return "Hello, World!"
|
=> Eff es (Html ())
|
||||||
|
rootHandler = (writeLog Info "Hit the root!")
|
||||||
|
>>= \_ -> return V.root
|
||||||
|
|
||||||
userListHandler :: CRUD es => Eff es [User]
|
userListHandler :: CRUD es
|
||||||
userListHandler = (writeLog Info "Selected all users!") >>= \_ ->
|
=> Eff es (Html ())
|
||||||
databaseRead_ "SELECT id, name FROM users"
|
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 ->
|
userGetHandler userId = databaseRead (queryUser userId) >>= \mUser ->
|
||||||
case mUser of
|
case mUser of
|
||||||
(a:_) -> pure a
|
(a:_) -> pure a
|
||||||
[] -> pure (User (UserId 0) "No user found")
|
[] -> 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 =
|
userPostHandler name =
|
||||||
databaseWrite (writeUser name) >>= \_ -> return NoContent
|
databaseWrite (writeUser name) >>= \_ -> return NoContent
|
||||||
|
|
||||||
userDeleteHandler :: CRUD es => UserId -> Eff es NoContent
|
userDeleteHandler :: CRUD es
|
||||||
|
=> UserId -> Eff es NoContent
|
||||||
userDeleteHandler userId =
|
userDeleteHandler userId =
|
||||||
databaseWrite (deleteUser userId) >>= \_ -> return NoContent
|
databaseWrite (deleteUser userId) >>= \_ -> return NoContent
|
||||||
|
@ -2,14 +2,15 @@ module Logger where
|
|||||||
|
|
||||||
import Core
|
import Core
|
||||||
import Database
|
import Database
|
||||||
|
import Utility
|
||||||
|
|
||||||
import Data.Time (getCurrentTime, UTCTime)
|
import Data.Time (getCurrentTime, UTCTime)
|
||||||
import Effectful
|
import Effectful
|
||||||
import Effectful.Dispatch.Dynamic
|
import Effectful.Dispatch.Dynamic
|
||||||
import Effectful.Error.Static (Error, throwError)
|
import Effectful.Error.Static (Error, throwError)
|
||||||
import Effectful.Reader.Static
|
import Effectful.Reader.Static
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
import Servant hiding ((:>))
|
import Servant hiding ((:>))
|
||||||
|
|
||||||
type LogToDatabase es = (Database :> es, Error ServerError :> es, IOE :> es)
|
type LogToDatabase es = (Database :> es, Error ServerError :> es, IOE :> es)
|
||||||
|
|
||||||
@ -19,29 +20,19 @@ writeLog :: (Logger :> es, Error ServerError :> es)
|
|||||||
=> LogLevel -> String -> Eff es ()
|
=> LogLevel -> String -> Eff es ()
|
||||||
writeLog level msg = send (WriteLog level msg)
|
writeLog level msg = send (WriteLog level msg)
|
||||||
|
|
||||||
runLoggerConsole :: (Error ServerError :> es, IOE :> es) =>
|
runLoggerConsole :: (Error ServerError :> es, IOE :> es)
|
||||||
Eff (Logger : es) a -> Eff es a
|
=> Eff (Logger : es) a -> Eff es a
|
||||||
runLoggerConsole = interpret $ \_ -> \case
|
runLoggerConsole = interpret $ \_ -> \case
|
||||||
WriteLog level msg -> do
|
WriteLog level msg -> do
|
||||||
time <- liftIOE getCurrentTime
|
time <- liftIOE getCurrentTime
|
||||||
liftIOE $ putStrLn
|
liftIOE $ putStrLn
|
||||||
$ "TIMESTAMP: " ++ show time ++ "\n"
|
$ "TIMESTAMP: " ++ show time ++ "\n"
|
||||||
++ "LEVEL: " ++ show level ++ "\n"
|
++ "LEVEL: " ++ show level ++ "\n"
|
||||||
++ "MESSAGE: " ++ msg
|
++ "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
|
runLoggerPSQL = interpret $ \_ -> \case
|
||||||
WriteLog level msg ->
|
WriteLog level msg ->
|
||||||
databaseWrite
|
databaseWrite
|
||||||
("INSERT INTO logs (level, message) VALUES (?,?);", (level, msg))
|
("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 Logger
|
||||||
import Routes
|
import Routes
|
||||||
|
|
||||||
import Control.Monad.Except (ExceptT (ExceptT))
|
import Control.Monad.Except (ExceptT (ExceptT))
|
||||||
import Data.List
|
import Data.List
|
||||||
import Effectful
|
import Effectful
|
||||||
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
|
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
|
||||||
import Effectful.Reader.Static
|
import Effectful.Reader.Static
|
||||||
import Network.Wai.Handler.Warp (run)
|
import Network.Wai.Handler.Warp (run)
|
||||||
import Servant hiding ((:>), throwError)
|
import Network.Wai.Middleware.Static ((>->), staticPolicy, noDots, addBase)
|
||||||
import qualified Servant as S
|
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
|
-- Application
|
||||||
--
|
--
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
pool <- createConnectionPool
|
(envPort, envMode) <- do
|
||||||
let env = AppEnv { pool = pool }
|
port <- lookupEnv "PORT"
|
||||||
runEffStack env $ databaseInit
|
env <- lookupEnv "ENVIRONMENT"
|
||||||
run port . serve proxy $ app env
|
pure (port, env)
|
||||||
|
|
||||||
app :: AppEnv -> Server AppAPI
|
let port = maybe 8080 read envPort
|
||||||
app env = transformEff env
|
mode = case envMode of
|
||||||
$ rootHandler
|
Just "production" -> Production
|
||||||
:<|> userListHandler
|
_ -> Debug
|
||||||
:<|> userGetHandler
|
|
||||||
:<|> userPostHandler
|
|
||||||
:<|> userDeleteHandler
|
|
||||||
|
|
||||||
transformEff :: AppEnv -> ServerT AppAPI AppEff -> ServerT AppAPI Handler
|
env <- case mode of
|
||||||
transformEff env = hoistServer proxy
|
Production -> do
|
||||||
$ Handler
|
dbPool <- createConnectionPool "host=localhost dbname=demo"
|
||||||
. ExceptT
|
let env = AppEnv { pool = Just dbPool }
|
||||||
. runEffStack env
|
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)
|
run port . middleware . serve proxy $ app env mode
|
||||||
runEffStack env = runEff
|
|
||||||
. runErrorNoCallStack
|
|
||||||
. runReader env
|
|
||||||
. runDatabaseIO
|
|
||||||
. runLoggerPSQL
|
|
||||||
|
|
||||||
port :: Int
|
app :: AppEnv -> RunMode -> Server AppAPI
|
||||||
port = 8080
|
app env Production = hoistServer proxy (Handler . ExceptT . appEff env) handlers
|
||||||
|
app env Debug = hoistServer proxy (Handler . ExceptT . appDebug env) handlers
|
||||||
|
|
||||||
proxy :: Proxy AppAPI
|
handlers :: ServerT AppAPI AppEff
|
||||||
proxy = Proxy
|
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 Core
|
||||||
|
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
import Lucid (Html)
|
||||||
import Servant
|
import Servant
|
||||||
|
import Servant.HTML.Lucid
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Routes
|
-- Routes
|
||||||
--
|
--
|
||||||
type Root = Get '[PlainText] T.Text
|
type Root = Get '[HTML] (Html ())
|
||||||
|
|
||||||
type UserList = "user"
|
type UserList = "user"
|
||||||
:> Get '[JSON] [User]
|
:> Get '[HTML] (Html ())
|
||||||
|
|
||||||
type UserGet = "user"
|
type UserGet = "user"
|
||||||
:> Capture "userId" UserId
|
:> 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