commit 6e2fb3b9bde386e14cb20999392fafd470b98c23 Author: James Eversole Date: Wed Sep 18 13:16:36 2024 -0500 init diff --git a/HELPS.cabal b/HELPS.cabal new file mode 100644 index 0000000..1444d34 --- /dev/null +++ b/HELPS.cabal @@ -0,0 +1,53 @@ +cabal-version: 1.12 + +name: HELPS +version: 0.0.1 +description: Haskell, Effectful, Lucid, PostgreSQL, Servant +author: James Eversole +maintainer: james@eversole.co +copyright: James Eversole +license: ISC +license-file: LICENSE +build-type: Simple +extra-source-files: + README + +executable Main + main-is: Main.hs + hs-source-dirs: + src + default-extensions: + BlockArguments + ConstraintKinds + DataKinds + DeriveAnyClass + DeriveGeneric + DerivingStrategies + FlexibleContexts + FlexibleInstances + GeneralizedNewtypeDeriving + LambdaCase + OverloadedRecordDot + OverloadedStrings + ScopedTypeVariables + StrictData + TypeFamilies + ghc-options: -threaded -rtsopts -with-rtsopts=-N -optl-pthread -fPIC + build-depends: + base + , aeson + , bytestring + , effectful + , exceptions + , lucid + , mtl + , postgresql-simple + , servant-server + , text + , utf8-string + , warp + other-modules: + Core + Database + Handlers + default-language: GHC2021 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb5cf51 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# servant-effectful-template + +A nix starting template for web projects utilizing + +- [Haskell](https://wiki.haskell.org/Haskell) +- [Effectful](https://github.com/haskell-effectful/effectful) +- [Lucid](https://github.com/chrisdone/lucid) +- [PostgreSQL](https://www.postgresql.org/) +- [Servant](https://github.com/haskell-servant/servant) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1f5ea31 --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1728764407, + "narHash": "sha256-J4kaIxwjrGVQkLA6njCFH09xj2oCf/VWFircIy7b65k=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4ada35702a7146e1df24f8d6987a1d7c1a5a4707", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f80fbbd --- /dev/null +++ b/flake.nix @@ -0,0 +1,62 @@ +{ + description = "HELPS"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + packageName = "HELPS"; + containerPackageName = "${packageName}-container"; + + 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.default = self.packages.${system}.${packageName}; + defaultPackage = self.packages.${system}.default; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + ghcid + cabal-install + ghc + ]; + inputsFrom = builtins.attrValues self.packages.${system}; + }; + devShell = self.devShells.${system}.default; + + packages.${containerPackageName} = pkgs.dockerTools.buildImage { + name = "HELPS"; + + copyToRoot = pkgs.buildEnv { + name = "image-root"; + paths = [ HELPS ]; + pathsToLink = [ "/bin" ]; + }; + tag = "latest"; + config = { + Cmd = [ + "/bin/Purr" + ]; + WorkingDir = "/app"; + ExposedPorts = { + "3000/tcp" = {}; + }; + extraCommands = '' + ''; + }; + }; + }); +} diff --git a/src/Core.hs b/src/Core.hs new file mode 100644 index 0000000..a8e4d03 --- /dev/null +++ b/src/Core.hs @@ -0,0 +1,54 @@ +module Core where + +import Control.Exception (IOException) +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Text as T +import Database.PostgreSQL.Simple (Query) +import Database.PostgreSQL.Simple.FromRow (FromRow, field, fromRow) +import Database.PostgreSQL.Simple.ToRow (ToRow, toRow) +import Database.PostgreSQL.Simple.ToField (ToField) +import Effectful +import Effectful.Dispatch.Dynamic +import Effectful.Error.Static (Error) +import Effectful.FileSystem +import GHC.Generics (Generic) +import Servant hiding ((:>)) +import qualified Servant as S + +-- Core data +data User = User { userId :: Int, userName :: String} + deriving (Show, Generic) + +instance FromRow User where + fromRow = User <$> field <*> field +instance ToRow User where + toRow (User uid name) = toRow (uid, name) + +instance ToJSON User +instance FromJSON User + +-- Effects +type AppEff = Eff '[Database, FileSystem, Error ServerError, IOE] + +data Database :: Effect where + DatabaseRead :: (Query, Int) -> Database (Eff es) (Maybe User) + DatabaseWrite :: (Query, String) -> Database (Eff es) () + +type instance DispatchOf Database = 'Dynamic + +databaseRead :: (Database :> es, Error ServerError :> es) => (Query, Int) -> Eff es (Maybe User) +databaseRead = send . DatabaseRead + +databaseWrite :: (Database :> es, Error ServerError :> es) => (Query, String) -> Eff es () +databaseWrite = send . DatabaseWrite + +-- Routes +type Root = Get '[PlainText] T.Text +type UserList = "user" S.:> Get '[JSON] [User] +type UserGet = "user" S.:> Capture "userId" Int S.:> Get '[JSON] User +type UserPost = "user" S.:> ReqBody '[PlainText] String S.:> PostCreated '[PlainText] NoContent + +type API = Root + :<|> UserList + :<|> UserGet + :<|> UserPost diff --git a/src/Database.hs b/src/Database.hs new file mode 100644 index 0000000..b82f068 --- /dev/null +++ b/src/Database.hs @@ -0,0 +1,58 @@ +module Database where + +import Core + +import Control.Exception (IOException) +import Control.Monad.Catch (catch) +import Data.Aeson (ToJSON) +import Data.ByteString.Lazy.UTF8 (fromString) +import Data.Maybe (listToMaybe) +import Database.PostgreSQL.Simple +import Effectful +import Effectful.Dispatch.Dynamic +import Effectful.Error.Static (Error, HasCallStack, catchError, runErrorNoCallStack, throwError) +import Servant hiding ((:>), throwError) + +runDatabaseDebug :: (IOE :> es, Error ServerError :> es) => Eff (Database : es) a -> Eff es a +runDatabaseDebug = interpret $ \_ -> \case + DatabaseRead (statement, values) -> adapt $ read statement values + DatabaseWrite (statement, values) -> adapt $ write statement values + where + read _ values = + putStrLn "We just mocked a READ database operation" >>= \_ -> pure $ + Just (User values "Mock User") + write _ values = + putStrLn $ "We just mocked a WRITE database operation with a user named " + ++ values + +runDatabaseIO :: (IOE :> es, Error ServerError :> es) => Eff (Database : es) a -> Eff es a +runDatabaseIO = interpret $ \_ -> \case + DatabaseRead (statement, values) -> adapt $ read statement values + DatabaseWrite (statement, values) -> adapt $ write statement values + where + read :: Query -> Int -> IO (Maybe User) + read statement values = do + conn <- openConn + user <- query conn statement (Only values) + pure $ listToMaybe user + write :: Query -> String -> IO () + write statement values = do + conn <- openConn + execute conn statement (Only values) + putStrLn $ "Wrote user to database using statement:\n" ++ show statement + openConn = connectPostgreSQL "host=localhost port=5432 dbname=demo connect_timeout=10" + +adapt :: (IOE :> es, Error ServerError :> es) => IO a -> Eff es a +adapt m = liftIO m `catch` \(e::IOException) -> + throwError $ ServerError + { errHTTPCode = 500 + , errReasonPhrase = "Internal Database Error" + , errBody = fromString $ show e + , errHeaders = [] + } + +queryUser :: Int -> (Query, Int) +queryUser userId = ("SELECT id, name FROM users WHERE id = ?;", userId) + +writeUser :: String -> (Query, String) +writeUser name = ("INSERT INTO users (name) VALUES (?);", name) diff --git a/src/Handlers.hs b/src/Handlers.hs new file mode 100644 index 0000000..fbca203 --- /dev/null +++ b/src/Handlers.hs @@ -0,0 +1,29 @@ +module Handlers where + +import Core +import Database + +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 Effectful.FileSystem +import Effectful.FileSystem.IO.ByteString as EBS +import Servant hiding ((:>), throwError) +import qualified Servant as S + +rootHandler :: (Error ServerError :> es) => Eff es T.Text +rootHandler = return "Hello, World!" + +userListHandler :: (Database :> es, Error ServerError :> es, FileSystem :> es) => Eff es [User] +userListHandler = mapM userGetHandler [1, 2, 3] + +userGetHandler :: (Database :> es, Error ServerError :> es, FileSystem :> es) => Int -> Eff es User +userGetHandler userId = databaseRead (queryUser userId) >>= \mUser -> + case mUser of + Just a -> pure a + Nothing -> pure (User 0 "No user found") + +userPostHandler :: (Database :> es, Error ServerError :> es, FileSystem :> es) => String -> Eff es NoContent +userPostHandler name = databaseWrite (writeUser name) >>= \_ -> return NoContent diff --git a/src/Main.hs b/src/Main.hs new file mode 100644 index 0000000..9809e1a --- /dev/null +++ b/src/Main.hs @@ -0,0 +1,33 @@ +module Main (main) where + +import Core +import Database +import Handlers + +import Control.Monad.Except (ExceptT (ExceptT)) +import Data.List +import Effectful +import Effectful.Error.Static (Error, runError, runErrorNoCallStack, throwError) +import Effectful.FileSystem +import Network.Wai.Handler.Warp (run) +import Servant hiding ((:>), throwError) +import qualified Servant as S + +main :: IO () +main = run port $ serve proxy app + +app :: Server API +app = α $ rootHandler + :<|> userListHandler + :<|> userGetHandler + :<|> userPostHandler + +α :: ServerT API AppEff -> ServerT API Handler +α = hoistServer proxy $ Handler . ExceptT . + runEff . runErrorNoCallStack . runFileSystem . runDatabaseIO + +port :: Int +port = 8080 + +proxy :: Proxy API +proxy = Proxy