simple 0.2.0 → 0.3.0
raw patch · 7 files changed
+509/−5 lines, 7 filesdep +base64-bytestringdep +mtldep +network
Dependencies added: base64-bytestring, mtl, network, postgresql-simple
Files
- Database/Connection.hs +47/−0
- Database/Migrations.hs +23/−0
- Database/PostgreSQL/Models.hs +253/−0
- Database/Sequel.hs +110/−0
- Web/Simple/Auth.hs +63/−0
- Web/Simple/Router.hs +1/−2
- simple.cabal +12/−3
+ Database/Connection.hs view
@@ -0,0 +1,47 @@+module Database.Connection where++import Control.Monad.IO.Class (liftIO, MonadIO)+import Control.Concurrent+import Control.Exception (finally)+import Database.PostgreSQL.Simple+import Network.URI+import System.IO.Unsafe++{-# NOINLINE conns #-}+conns :: Chan Connection+conns = unsafePerformIO $ newChan++createConnection :: ConnectInfo -> IO ()+createConnection config = connect config >>= writeChan conns++parseDbURL :: String -> ConnectInfo+parseDbURL uri = case mdbURI of+ Nothing -> defaultConnectInfo+ Just dbURI | uriScheme dbURI == "postgres:" ->+ let auth = uriAuthority dbURI+ in ConnectInfo { connectPort = toPort auth+ , connectHost = toHostname auth+ , connectUser = toUsername auth+ , connectPassword = toPassword auth+ , connectDatabase = toDatabase . uriPath $ dbURI}+ | otherwise -> defaultConnectInfo+ where mdbURI = parseURI uri+ toPort (Just (URIAuth _ _ "")) = 5432+ toPort (Just (URIAuth _ _ (':':p))) = read p+ toPort _ = 5432+ toHostname (Just (URIAuth _ h _)) = h+ toHostname _ = "localhost"+ toUsername (Just (URIAuth ua _ _)) = takeWhile (\x -> x /= ':' && x /= '@') ua+ toUsername Nothing = ""+ toPassword (Just (URIAuth ua _ _)) = case dropWhile (/= ':') ua of+ (':':password) -> takeWhile (/= '@') password+ _ -> ""+ toPassword Nothing = ""+ toDatabase ('/':db) = db+ toDatabase _ = ""++withConnection :: MonadIO m => (Connection -> IO b) -> m b+withConnection func = liftIO $ do+ conn <- readChan conns+ finally (func conn) (writeChan conns conn)+
+ Database/Migrations.hs view
@@ -0,0 +1,23 @@+module Database.Migrations+ ( defaultMain+ , module Database.Sequel+ ) where++import Prelude hiding (catch)+import Control.Exception+import Database.Connection+import Database.Sequel+import Database.PostgreSQL.Simple+import System.Environment++defaultMain :: Sequel () -> Sequel () -> IO ()+defaultMain up down = do+ dbURI <- catch (getEnv "DATABASE_URL")+ (const $ return "postgres://" :: SomeException -> IO String)+ createConnection $ parseDbURL dbURI++ args <- getArgs+ case args of+ "--rollback":_ -> runSequel down >>= withConnection . (flip execute_)+ _ -> runSequel up >>= withConnection . (flip execute_)+ return ()
+ Database/PostgreSQL/Models.hs view
@@ -0,0 +1,253 @@+{-# LANGUAGE MultiParamTypeClasses, TypeFamilies, FlexibleContexts #-}+{- |++Type classes for PostgreSQL-backed data models.++-}++module Database.PostgreSQL.Models+ ( FromParams(..), Param+ , PostgreSQLModel(..)+ , HasMany(..)+ , TableName(..), fromTableName+ , fromString, IsString+ ) where++import Data.List (intersperse)+import Data.String+import Database.PostgreSQL.Simple+import Database.PostgreSQL.Simple.ToField+import Database.PostgreSQL.Simple.ToRow+import Database.PostgreSQL.Simple.FromField+import Network.Wai.Parse++class FromParams p where+ -- | Converts an HTML form into a type+ fromParams :: [Param] -> Maybe p++instance (FromParams a, FromParams b) => FromParams (a, b) where+ fromParams params = do+ a <- fromParams params+ b <- fromParams params+ return (a, b)++-- | Wrapper type representing PostgreSQL table names+newtype TableName p = TableName String deriving (Show, Eq)++-- | Unwraps a 'TableName'+fromTableName :: TableName p -> String+fromTableName (TableName s) = s++-- | Basis type for PostgreSQL-backed data-models. Instances must, at minimum,+-- implement 'primaryKey', 'tableName' and 'columns'. /Note: the column ordering+-- must match that used in the type's implementation of 'FromRow' and 'ToRow'/+class (FromRow p, ToRow p, ToField (PrimaryKey p), FromField (PrimaryKey p))+ => PostgreSQLModel p where++ type PrimaryKey p :: *++ -- | Given a model, returns the value of it's primary key. In many cases, this+ -- will simply be an alias to a record accessor.+ primaryKey :: p -> Maybe (PrimaryKey p)++ -- | Returns the 'TableName' of the model. Instances should have a top-level value+ -- for the 'TableName' that is always returned. For example:+ --+ -- > employees :: TableName Employee+ -- > employees = TableName "employees"+ -- >+ -- > instance PostgreSQLModel Employee where+ -- > ...+ -- > tableName _ = employess+ -- + tableName :: p -> TableName p++ -- | Column names excluding primary key. Column order /must/ match the ordering+ -- used in 'ToRow' and 'FromRow'.+ columns :: TableName p -> [String]++ -- | Name of primary key column (default: \"id\").+ primaryKeyName :: TableName p -> String+ primaryKeyName _ = "id"++ -- | Column names with primary-key name prepended.+ columns_ :: TableName p -> [String]+ columns_ tName = (primaryKeyName tName):(columns tName)++ orderBy :: TableName p -> Maybe String+ orderBy _ = Nothing++ -- | Inserts the model into the database. It relies on the primary key+ -- being autogenerated by the database.+ insert :: p -> Connection -> IO (PrimaryKey p)+ insert model conn = do+ query conn template fields >>= return . head . head+ where template = fromString $ concat+ [ "insert into " + , fromTableName tName+ , " (" ++ cols ++ ")"+ , " VALUES (" ++ qs ++ ") RETURNING "+ , primaryKeyName tName]+ tName = tableName model+ qs = concat $ intersperse ", " $ map (const "?") $ colNames+ cols = concat $ intersperse ", " $ colNames+ colNameFields = case primaryKey model of+ Nothing -> (columns tName, toRow model)+ Just pkey -> ( columns_ tName+ , (toField pkey):(toRow model))+ (_, fields) = colNameFields+ (colNames, _) = colNameFields+ + -- | Create or update the model (uses the primary key to determine if+ -- the model already exists in the database)+ upsert :: p -> Connection -> IO (PrimaryKey p)+ upsert model conn = do+ case primaryKey model of+ Nothing -> insert model conn+ Just pkey -> do+ execute conn template $ toRow model ++ [toField pkey]+ return pkey+ where template = fromString $ concat+ ["update "+ , fromTableName tName+ , " SET "+ , cols, " where ", primaryKeyName tName+ , " = ?"]+ tName = tableName model+ cols = concat $ intersperse ", " $ map (++ " =?") (columns tName)++ -- | Retrieves the single model corresponding to the given primary key+ find :: TableName p -> PrimaryKey p -> Connection -> IO (Maybe p)+ find tn = findFirst tn (primaryKeyName tn)++ -- | Finds the first model in the database based on the column-value contstraint.+ findFirst :: ToField f+ => TableName p+ -> String -- ^ Search column name+ -> f -- ^ Search value+ -> Connection -> IO (Maybe p)+ findFirst tName col val conn = do+ models <- query conn template (Only val)+ case models of+ (model:_) -> return $ Just model+ [] -> return Nothing+ where template = fromString $ concat+ ["select ", cols, " from "+ , fromTableName tName+ , " where "+ , col+ , " = ?"+ , maybe "" (" order by " ++) $ orderBy tName+ , " limit 1"]+ cols = concat $ intersperse ", " $ columns_ tName++ -- | Retrieves all models in the table+ findAll :: TableName p -> Connection -> IO [p]+ findAll tName conn = query_ conn template+ where template = fromString $ concat + [ "select ", cols, " from "+ , fromTableName tName+ , maybe "" (" order by " ++) $ orderBy tName]+ cols = concat $ intersperse ", " $ columns_ tName++ -- | Retrieves all models in the table subject to the column-value constraint.+ findAllBy :: ToField f+ => TableName p+ -> String -- ^ Search column name+ -> f -- ^ Search value+ -> Connection -> IO [p]+ findAllBy tName col val conn = query conn template (Only val)+ where template = fromString $ concat + [ "select ", cols, " from "+ , fromTableName tName+ , " where ", col, " = ?"+ , maybe "" (" order by " ++) $ orderBy tName]+ cols = concat $ intersperse ", " $ columns_ tName++-- | Defines a \"has-many\" relationship between two models, where the 'parent'+-- model may be associated with zero or more of the 'child' model. Specifically,+-- the 'child' table has a foreign key column pointing to the parent model.+class (PostgreSQLModel parent, PostgreSQLModel child) =>+ HasMany parent child where+ + foreignKey :: TableName parent -> TableName child -> String+ foreignKey tName _ = fromTableName tName ++ "_" ++ (primaryKeyName tName)++ childrenOf :: parent -> TableName child -> Connection -> IO [child]+ childrenOf parent ctName conn = query conn template (Only $ primaryKey parent)+ where template = fromString $ concat $+ [ "select ", childColumns, " from "+ , fromTableName ctName+ , " where "+ , foreignKey ptName ctName+ , " = ?"+ , maybe "" (" order by " ++) $ orderBy ctName]+ ptName = tableName parent+ childColumns = concat $ intersperse ", " $ columns_ ctName++ childOf :: parent -> TableName child+ -> PrimaryKey child -> Connection -> IO (Maybe child)+ childOf parent ctName v = childOfBy parent ctName (primaryKeyName ctName) v++ childOfBy :: ToField v+ => parent+ -> TableName child+ -> String+ -> v+ -> Connection -> IO (Maybe child)+ childOfBy parent ctName col pkeyc conn = do+ mchildren <- query conn template (primaryKey parent, pkeyc) + case mchildren of+ [] -> return Nothing+ (c:_) -> return $ Just c+ where template = fromString $ concat $+ [ "select ", childColumns, " from "+ , fromTableName ctName+ , " where "+ , foreignKey ptName ctName, " = ?"+ , " and ", col, " = ?"+ , maybe "" (" order by " ++) $ orderBy ctName+ , " limit 1"]+ ptName = tableName parent+ childColumns = concat $ intersperse ", " $ columns_ ctName++ childrenOfBy :: ToField f+ => parent+ -> TableName child+ -> String+ -> f+ -> Connection -> IO [child]+ childrenOfBy parent ctName col val conn =+ query conn template (primaryKey parent, val)+ where template = fromString $ concat $+ [ "select ", childColumns, " from "+ , fromTableName ctName+ , " where "+ , foreignKey ptName ctName+ , " = ? and ", col, " = ?"+ , maybe "" (" order by " ++) $ orderBy ctName]+ ptName = tableName parent+ childColumns = concat $ intersperse ", " $ columns_ ctName++ insertFor :: parent -> child -> Connection -> IO (PrimaryKey child)+ insertFor parent chld conn = do+ query conn template (fields ++ [toField $ primaryKey parent])+ >>= return . head . head+ where template = fromString $ concat+ [ "insert into " + , fromTableName ctName+ , " (", cols, ", ", foreignKey ptName ctName, ")"+ , " VALUES (", qs, ", ?) "+ , " RETURNING "+ , primaryKeyName ctName]+ ctName = tableName chld+ ptName = tableName parent+ qs = concat $ intersperse ", " $ map (const "?") $ colNames+ cols = concat $ intersperse ", " $ colNames+ colNameFields = case primaryKey chld of+ Nothing -> (columns ctName, toRow chld)+ Just pkey -> ( columns_ ctName+ , (toField pkey):(toRow chld))+ (_, fields) = colNameFields+ (colNames, _) = colNameFields+
+ Database/Sequel.hs view
@@ -0,0 +1,110 @@+module Database.Sequel where++import Control.Monad.State+import Data.List+import Data.String++type Sequel = StateT String IO++runSequel :: IsString s => Sequel a -> IO s+runSequel sql = fmap (fromString . snd) $ runStateT sql ""++append :: String -> Sequel ()+append str1 = modify $ \str0 -> concat $+ [ str0+ , "\n\n"+ , str1]++drop_table :: String -> Sequel ()+drop_table name = append $ "DROP TABLE " ++ name ++ ";"++create_table :: String -> CreateTable a -> Sequel ()+create_table name block = do+ blk <- runCreateTable $ do+ block+ append $ concat $+ [ "CREATE TABLE " ++ name ++ " (\n"+ , blk+ , "\n);"]++type ColumnType = String++serial :: ColumnType+serial = "serial"++integer :: ColumnType+integer = "integer"++time :: ColumnType+time = "time"++varchar :: Integer -> ColumnType+varchar size = "varchar(" ++ (show size) ++ ")"++string :: ColumnType+string = varchar 255++text :: ColumnType+text = "text"++boolean :: ColumnType+boolean = "boolean"++data ColumnConstraint = NOT_NULL+ | UNIQUE+ | PRIMARY_KEY+ | DEFAULT String+ | REFERENCES String String++stringifyConstraint :: ColumnConstraint -> String+stringifyConstraint NOT_NULL = "NOT NULL"+stringifyConstraint UNIQUE = "UNIQUE"+stringifyConstraint PRIMARY_KEY = "PRIMARY KEY"+stringifyConstraint (DEFAULT str) = "DEFAULT " ++ str+stringifyConstraint (REFERENCES table col) =+ "REFERENCES " ++ table ++ "(" ++ col ++ ")"++drop_column :: String -> String -> Sequel ()+drop_column tableName colName = append $ concat $+ [ "ALTER TABLE "+ , tableName+ , " DROP COLUMN "+ , colName, ";"]++add_column :: String -> String -> ColumnType -> [ColumnConstraint] -> Sequel ()+add_column tableName colName colType ctrs = append $ concat $+ [ "ALTER TABLE "+ , tableName+ , " ADD COLUMN "+ , colName+ , " "+ , colType+ , " "+ , concat $ intersperse " " (map stringifyConstraint ctrs), ";"]++rename_column :: String -> String -> String -> Sequel ()+rename_column tableName fromName toName = append $ concat $+ [ "ALTER TABLE "+ , tableName+ , " RENAME COLUMN "+ , fromName+ , " TO "+ , toName+ , ";"]++type CreateTable = StateT [(ColumnType, String, [ColumnConstraint])] Sequel++runCreateTable :: CreateTable a -> Sequel String+runCreateTable ct = do+ (_, cols) <- runStateT ct []+ let colStrs = map+ (\(t, name, crts) ->+ " " ++ name ++ " " ++ t ++ " " +++ (concat $ intersperse " " (map stringifyConstraint crts))+ ) cols+ return $ concat $ intersperse ",\n" (reverse colStrs)++column :: String -> ColumnType -> [ColumnConstraint] -> CreateTable ()+column colName colType constraints = do+ modify $ \cols -> (colType, colName, constraints):cols+
+ Web/Simple/Auth.hs view
@@ -0,0 +1,63 @@+{-# LANGUAGE OverloadedStrings, Rank2Types #-}+module Web.Simple.Auth where++import Control.Monad.IO.Class (liftIO)+import Data.ByteString.Base64+import qualified Data.ByteString.Char8 as S8+import Network.HTTP.Types+import Network.Wai+import Web.Simple.Responses+import Web.Simple.Router++-- | An 'AuthRouter' authenticates a 'Request' and, if successful, forwards the+-- 'Request' to the 'Routeable'.+type AuthRouter r = Routeable r+ => (Request -> S8.ByteString+ -> S8.ByteString+ -> IO (Maybe Request))+ -> r+ -> Route ()++-- | An 'AuthRouter' that uses HTTP basic authentication to authenticate a request+-- in a particular realm.+basicAuthRoute :: String -> AuthRouter r+basicAuthRoute realm testAuth rt = Route (\req -> do+ didAuthenticate <-+ case lookup hAuthorization (requestHeaders req) of+ Nothing -> return Nothing+ Just authStr+ | S8.take 5 authStr /= "Basic" -> return Nothing+ | otherwise -> do+ let up = fmap (S8.split ':') $ decode $ S8.drop 6 authStr+ case up of+ Right (user:pass:[]) -> liftIO $ testAuth req user pass+ _ -> return Nothing+ case didAuthenticate of+ Nothing -> return $ Just $ requireBasicAuth realm+ Just finReq -> runRoute rt finReq+ ) ()++-- | Wraps an 'AuthRouter' to take a simpler authentication function (that just+-- just takes a username and password, and returns 'True' or 'False'). It also+-- adds an \"X-User\" header to the 'Request' with the authenticated user\'s+-- name (the first argument to the authentication function).+authRewriteReq :: Routeable r+ => AuthRouter r+ -> (S8.ByteString -> S8.ByteString -> IO Bool)+ -> r+ -> Route ()+authRewriteReq authRouter testAuth rt =+ authRouter (\req user pwd -> do+ success <- testAuth user pwd+ if success then+ return $ Just $ transReq req user+ else return Nothing) rt+ where transReq req user = req { requestHeaders = ("X-User", user):(requestHeaders req)}++-- | A 'Route' that uses HTTP basic authentication to authenticate a request for a realm+-- with the given username ans password. The request is rewritten with an 'X-User' header+-- containing the authenticated username before being passed to the next next 'Route'.+basicAuth :: Routeable r => String -> S8.ByteString -> S8.ByteString -> r -> Route ()+basicAuth realm user pass = authRewriteReq (basicAuthRoute realm)+ (\u p -> return $ u == user && p == pass)+
Web/Simple/Router.hs view
@@ -35,7 +35,6 @@ import Network.Wai import Web.Simple.Responses - {- | 'Routeable' types can be converted into a route function using 'runRoute'. If the route is matched it returns a 'Response', otherwise 'Nothing'.@@ -176,7 +175,7 @@ routePattern :: Routeable r => S.ByteString -> r -> Route () routePattern pattern route = let patternParts = map T.unpack $ decodePathSegments pattern- in routeTop $ foldr mkRoute (mroute . runRoute $ route) patternParts+ in foldr mkRoute (mroute . runRoute $ routeTop route) patternParts where mkRoute (':':varName) = routeVar (S8.pack varName) mkRoute varName = routeName (S8.pack varName)
simple.cabal view
@@ -2,7 +2,7 @@ -- documentation, see http://haskell.org/cabal/users-guide/ name: simple-version: 0.2.0+version: 0.3.0 synopsis: A minimalist web framework for the WAI server interface description: @@ -48,18 +48,27 @@ library build-depends: base >=4.5 && < 5.0,+ base64-bytestring >= 1.0 && < 1.1, conduit >= 0.5, wai >= 1.3 && < 2.0, wai-extra >= 1.3 && < 2.0, http-types >= 0.7.1, text >= 0.11, transformers >= 0.3,- bytestring >= 0.9+ bytestring >= 0.9,+ postgresql-simple >= 0.2.4.1,+ network >= 2.4,+ mtl >= 2.1 - ghc-options: -Wall+ ghc-options: -Wall -fno-warn-unused-do-bind exposed-modules:+ Database.PostgreSQL.Models,+ Database.Connection,+ Database.Migrations,+ Database.Sequel, Web.Simple,+ Web.Simple.Auth, Web.Simple.Controller, Web.Simple.Responses, Web.Simple.Router,