Merge pull request #75 from begriffs/errors

Errors
This commit is contained in:
Joe Nelson
2014-10-13 18:23:59 -07:00
10 changed files with 138 additions and 92 deletions
+2 -30
View File
@@ -6,7 +6,6 @@ module Dbapi where
import Types (SqlRow, getRow)
import Control.Monad (join)
import Control.Exception.Base (bracket_)
import Control.Arrow ((***))
import Control.Applicative
import Options.Applicative hiding (columns)
@@ -42,7 +41,6 @@ import qualified Data.Aeson as JSON
import PgQuery
import RangeQuery
import Data.Ranged.Ranges (emptyRange)
import Codec.Binary.Base64.String (decode)
-- }}}
@@ -72,34 +70,8 @@ filterByKeys m keys =
if null keys then m else
m `intersection` fromList (zip keys $ repeat undefined)
httpRequesterRole :: RequestHeaders -> Connection -> IO LoginAttempt
httpRequesterRole hdrs conn = do
let auth = fromMaybe "" $ lookup hAuthorization hdrs
case BS.split ' ' (cs auth) of
("Basic" : b64 : _) ->
case BS.split ':' $ cs (decode $ cs b64) of
(u:p:_) -> signInRole u p conn
_ -> return MalformedAuth
_ -> return NoCredentials
app :: Connection -> DbRole -> Application
app conn anonymous req respond = do
attempt <- httpRequesterRole (requestHeaders req) conn
case attempt of
MalformedAuth ->
respond $ responseLBS status400 [] "Malformed basic auth header"
LoginFailed ->
respond $ responseLBS status401 [] "Invalid username or password"
LoginSuccess role ->
bracket_ (pgSetRole conn role) (pgResetRole conn) $ appWithRole conn req respond
NoCredentials ->
bracket_ (pgSetRole conn anonymous) (pgResetRole conn) $ appWithRole conn req respond
appWithRole :: Connection -> Application
appWithRole conn req respond =
app :: Connection -> Application
app conn req respond =
respond =<< case (path, verb) of
([], _) ->
responseLBS status200 [jsonContentType] <$> printTables ver conn
+5 -10
View File
@@ -1,10 +1,9 @@
{-# LANGUAGE OverloadedStrings #-}
-- {{{ Imports
module Main where
import Dbapi
import Middleware (reportPgErrors, redirectInsecure)
import Middleware (inTransaction, authenticated, withSavepoint, clientErrors,
redirectInsecure)
import Network.Wai.Handler.Warp hiding (Connection)
import Database.HDBC.PostgreSQL (connectPostgreSQL')
import Data.String.Conversions (cs)
@@ -15,8 +14,6 @@ import Options.Applicative hiding (columns)
import Network.Wai.Middleware.Gzip (gzip, def)
import Network.Wai.Middleware.Cors (cors)
-- }}}
argParser :: Parser AppConfig
argParser = AppConfig
<$> strOption (long "db" <> short 'd' <> metavar "URI"
@@ -40,10 +37,8 @@ main = do
Prelude.putStrLn $ "Listening on port " ++ (show $ configPort conf :: String)
conn <- connectPostgreSQL' dburi
run port $ (if configSecure conf then redirectInsecure else id)
$ gzip def
$ cors corsPolicy
$ reportPgErrors
$ app conn (cs $ configAnonRole conf)
. gzip def . cors corsPolicy . clientErrors
$ (inTransaction . authenticated (cs $ configAnonRole conf) . withSavepoint)
app conn
where
describe = progDesc "create a REST API to an existing Postgres database"
+59 -12
View File
@@ -3,17 +3,64 @@
module Middleware where
import Data.Aeson
import Network.HTTP.Types.Header (hContentType, hLocation)
import Network.HTTP.Types.Status (status400, status301)
import Database.HDBC.Types (SqlError(..))
import Control.Exception (catchJust)
import Data.String.Conversions (cs)
import Network.Wai
import Network.URI (URI(..), parseURI)
import Data.Aeson ((.=), toJSON, ToJSON, object, encode)
import Data.Maybe (fromMaybe)
import Data.Monoid (mconcat)
import Database.HDBC (runRaw)
import Database.HDBC.PostgreSQL (Connection)
import Database.HDBC.Types (SqlError(..))
import Data.String.Conversions(cs)
import qualified Data.ByteString.Char8 as BS
import Control.Exception (finally, throw, catchJust, catch, SomeException,
bracket_)
import Network.HTTP.Types.Header (RequestHeaders, hContentType, hAuthorization,
hLocation)
import Network.HTTP.Types.Status (status400, status401, status301)
import Network.Wai (Application, requestHeaders, responseLBS, rawPathInfo,
rawQueryString, isSecure)
import Network.URI (URI(..), parseURI)
import PgQuery(LoginAttempt(..), signInRole, setRole, resetRole)
import Codec.Binary.Base64.String (decode)
inTransaction :: (Connection -> Application) -> Connection -> Application
inTransaction app conn req respond =
finally (runRaw conn "begin" >> app conn req respond) (runRaw conn "commit")
withSavepoint :: (Connection -> Application) -> Connection -> Application
withSavepoint app conn req respond = do
runRaw conn "savepoint req_sp"
catch (app conn req respond) (\e -> let _ = (e::SomeException) in
runRaw conn "rollback to savepoint req_sp" >> throw e)
authenticated :: BS.ByteString -> (Connection -> Application) ->
Connection -> Application
authenticated anon app conn req respond = do
attempt <- httpRequesterRole (requestHeaders req)
case attempt of
MalformedAuth ->
respond $ responseLBS status400 [] "Malformed basic auth header"
LoginFailed ->
respond $ responseLBS status401 [] "Invalid username or password"
LoginSuccess role ->
bracket_ (setRole conn role) (resetRole conn) $ app conn req respond
NoCredentials ->
bracket_ (setRole conn anon) (resetRole conn) $ app conn req respond
where
httpRequesterRole :: RequestHeaders -> IO LoginAttempt
httpRequesterRole hdrs = do
let auth = fromMaybe "" $ lookup hAuthorization hdrs
case BS.split ' ' (cs auth) of
("Basic" : b64 : _) ->
case BS.split ':' $ cs (decode $ cs b64) of
(u:p:_) -> signInRole u p conn
_ -> return MalformedAuth
_ -> return NoCredentials
instance ToJSON SqlError where
toJSON t = object [
"error" .= object [
@@ -23,8 +70,8 @@ instance ToJSON SqlError where
]
]
reportPgErrors :: Middleware
reportPgErrors app req respond =
clientErrors :: Application -> Application
clientErrors app req respond =
catchJust isPgException (app req respond) (
respond . responseLBS status400 [(hContentType, "application/json")]
. encode
@@ -35,7 +82,7 @@ reportPgErrors app req respond =
isPgException = Just
redirectInsecure :: Middleware
redirectInsecure :: Application -> Application
redirectInsecure app req respond = do
let hdrs = requestHeaders req
host = lookup "host" hdrs
+10 -8
View File
@@ -8,8 +8,8 @@ module PgQuery (
, upsert
, addUser
, signInRole
, pgSetRole
, pgResetRole
, setRole
, resetRole
, checkPass
, RangedResult(..)
, LoginAttempt(..)
@@ -165,9 +165,11 @@ placeholders :: String -> SqlRow -> String
placeholders symbol = intercalate ", " . map (const symbol) . getRow
insertClause :: Schema -> Text -> SqlRow -> QuotedSql
insertClause schema table (SqlRow []) =
("insert into %I.%I default values returning *", [toSql schema, toSql table])
insertClause schema table row =
("insert into %I.%I (" ++ placeholders "%I" row ++ ")",
map toSql $ cs schema : table : sqlRowColumns row)
("insert into %I.%I (" ++ placeholders "%I" row ++ ")",
map toSql $ cs schema : table : sqlRowColumns row)
<> (" values (" ++ placeholders "?" row ++ ") returning *", sqlRowValues row)
@@ -201,10 +203,10 @@ populateSql conn sql = do
ph :: [a] -> String
ph = intercalate ", " . map (const "?::varchar")
pgSetRole :: Connection -> DbRole -> IO ()
pgSetRole conn role = do
setRole :: Connection -> DbRole -> IO ()
setRole conn role = do
query <- populateSql conn ("set role %I", [toSql role])
void $ run conn query []
pgResetRole :: Connection -> IO ()
pgResetRole conn = void $ run conn "reset role" []
resetRole :: Connection -> IO ()
resetRole conn = void $ run conn "reset role" []
+5 -8
View File
@@ -12,16 +12,13 @@ import SpecHelper
spec :: Spec
spec = around appWithFixture $
describe "authorization" $ do
it "hides tables that anonymous does not own" $ do
pendingWith_ "Fix pg exception"
it "hides tables that anonymous does not own" $
get "/authors_only" `shouldRespondWith` 400 -- TODO: should be 404
it "indicates login failure" $ do
pendingWith_ "Fix pg exception"
let auth = authHeader "dbapi_test_author_a" "fakefake"
request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 401
it "allows users with permissions to see their tables" $ do
pendingWith_ "Fix pg exception"
let auth = authHeader "dbapi_test_author_a" ""
request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 400
-- it "allows users with permissions to see their tables" $ do
-- let auth = authHeader "dbapi_test_author_a" ""
-- request methodGet "/authors_only" [auth] ""
-- `shouldRespondWith` 200
+2 -3
View File
@@ -27,7 +27,7 @@ spec = around appWithFixture $ do
[json| {
"integer": 13, "double": 3.14159, "varchar": "testing!"
, "boolean": false, "date": "01/01/1900", "money": "$3.99"
, "enum": ["foo"]
, "enum": "foo"
} |]
`shouldRespondWith` 201
@@ -47,8 +47,7 @@ spec = around appWithFixture $ do
incNullableStr record `shouldBe` Nothing
context "into a table with simple pk" $
it "fails with 400 and error" $ do
pendingWith_ "Fix pg exception"
it "fails with 400 and error" $
post "/simple_pk" [json| { "extra":"foo"} |]
`shouldRespondWith` 400
+1
View File
@@ -35,6 +35,7 @@ uRole = "dbapi_test"} in
describe "Table info" $ do
it "is available with OPTIONS verb" $
pending_ >>
request methodOptions "/menagerie" [] "" `shouldRespondWith`
[json|
{
+12 -21
View File
@@ -9,18 +9,18 @@ import Database.HDBC
import Database.HDBC.PostgreSQL
import Data.String.Conversions (cs)
import Control.Exception.Base (bracket, finally, tryJust)
import Control.Monad (when)
import Control.Exception.Base (bracket, finally)
import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange,
hRange, hAuthorization)
import Codec.Binary.Base64.String (encode)
import Data.CaseInsensitive (CI(..))
import Text.Regex.TDFA ((=~))
import qualified Data.HashMap.Strict as Hash
import qualified Data.ByteString.Char8 as BS
import Network.Wai.Middleware.Cors (cors)
import Middleware(clientErrors, withSavepoint, authenticated)
import Dbapi (app, corsPolicy, AppConfig(..))
import PgQuery(addUser)
@@ -59,23 +59,15 @@ withUser name pass role action conn = do
withApp :: ActionWith Application -> ActionWith Connection
withApp action conn = do
runRaw conn "begin;"
action $ cors corsPolicy $ app conn "dbapi_anonymous"
action $ cors corsPolicy $ authenticated "dbapi_anonymous" app conn
rollback conn
appWithFixture :: ActionWith Application -> IO ()
appWithFixture action = withDatabaseConnection $ \c -> do
result <- tryJust transactionAborted $ do
runRaw c "begin;"
action $ cors corsPolicy $ app c "dbapi_anonymous"
rollback c
when (isLeft result) $
putStrLn "note: commands ignored after aborted transaction"
where
transactionAborted :: SqlError -> Maybe ()
transactionAborted e =
if seState e == "25P02" then Just () else Nothing
runRaw c "begin;"
action $ cors corsPolicy . clientErrors $
(authenticated "dbapi_anonymous" . withSavepoint) app c
rollback c
rangeHdrs :: ByteRange -> [Header]
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
@@ -84,8 +76,7 @@ rangeUnit :: Header
rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")
getHeader :: CI BS.ByteString -> [Header] -> Maybe BS.ByteString
getHeader name headers =
Hash.lookup name $ Hash.fromList headers
getHeader = lookup
matchHeader :: CI BS.ByteString -> String -> [Header] -> Bool
matchHeader name valRegex headers =
@@ -93,12 +84,12 @@ matchHeader name valRegex headers =
authHeader :: String -> String -> Header
authHeader user pass =
(hAuthorization, cs $ "Basic: " ++ encode (user ++ ":" ++ pass))
(hAuthorization, cs $ "Basic " ++ encode (user ++ ":" ++ pass))
-- for hspec-wai
pending_ :: WaiSession ()
pending_ = liftIO pending
pending_ = liftIO Test.Hspec.pending
-- for hspec-wai
pendingWith_ :: String -> WaiSession ()
pendingWith_ = liftIO . pendingWith
pendingWith_ = liftIO . Test.Hspec.pendingWith
+36
View File
@@ -0,0 +1,36 @@
{-# LANGUAGE OverloadedStrings #-}
module Unit.ErrorsSpec where
import Test.Hspec
import Database.HDBC (runRaw, quickQuery, fromSql, SqlError)
import SpecHelper (dbWithSchema)
import Middleware (withSavepoint)
import PgQuery (insert)
import Types(SqlRow(..))
import Control.Exception(catch)
import Control.Monad(void)
import Network.Wai (defaultRequest, responseLBS)
import Network.HTTP.Types.Status (ok200)
spec :: Spec
spec = let
dbErrApp conn _ res = do
putStrLn "In fake app"
_ <- insert "1" "items" (SqlRow []) conn
runRaw conn "select 1/0"
_ <- insert "1" "items" (SqlRow []) conn
res $ responseLBS ok200 [("Content-Type", "application/json")] "{}"
in around dbWithSchema $
describe "withSavepoint" $
it "allows partial rollback of request" $ \c -> do
let app = withSavepoint dbErrApp c
[[beforeCount]] <- quickQuery c "select count(*) from \"1\".items" []
runRaw c "set role dbapi_anonymous"
_ <- insert "1" "items" (SqlRow []) c
catch (void $ app defaultRequest (const undefined) ) $
\e -> let _ = (e::SqlError) in do
_ <- insert "1" "items" (SqlRow []) c
[[afterCount]] <- quickQuery c "select count(*) from \"1\".items" []
fromSql afterCount `shouldBe` (fromSql beforeCount::Int) + 2
+6
View File
@@ -53,6 +53,12 @@ spec = around dbWithSchema $ do
("nullable_string", toSql ("a string"::String))]) conn
`shouldThrow` \e -> seState e == "23502"
it "generates a default values query if no data is provided" $ \c -> do
r <- insert "1" "items" (SqlRow []) c
let [row] = toList r
quickALQuery c "select * from \"1\".items where id = ?" [snd row]
`shouldReturn` [[row]]
let {user = "jdoe"; pass = "secret"; role = "test_default_role"}
describe "addUser" $ do
it "adds a correct user to the right table" $ \conn -> do