236 lines
7.7 KiB
Haskell
236 lines
7.7 KiB
Haskell
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
-- {{{ Imports
|
|
module Dbapi where
|
|
|
|
import Types (SqlRow, getRow)
|
|
import Middleware(reportPgErrors)
|
|
|
|
import Control.Monad (join)
|
|
import Control.Exception.Base (bracket_)
|
|
import Control.Arrow ((***))
|
|
import Control.Applicative
|
|
import Options.Applicative hiding (columns)
|
|
|
|
import Data.Maybe (fromMaybe, isJust)
|
|
import Text.Regex.TDFA ((=~))
|
|
import Data.Map (intersection, fromList, toList, Map)
|
|
import Data.List (sort)
|
|
import qualified Data.Set as S
|
|
import Data.Convertible.Base (convert)
|
|
import Data.Text (strip)
|
|
|
|
import Network.HTTP.Types.Status
|
|
import Network.HTTP.Types.Header
|
|
import Network.HTTP.Types.URI
|
|
|
|
import Network.HTTP.Base (urlEncodeVars)
|
|
|
|
import Network.Wai
|
|
import Network.Wai.Internal
|
|
import Network.Wai.Middleware.Cors (CorsResourcePolicy(..))
|
|
|
|
import qualified Data.ByteString.Char8 as BS
|
|
import Data.String.Conversions (cs)
|
|
import qualified Data.CaseInsensitive as CI
|
|
|
|
import Database.HDBC.PostgreSQL (Connection)
|
|
import PgStructure (printTables, printColumns, primaryKeyColumns,
|
|
columns, Column(colName))
|
|
|
|
import qualified Data.Aeson as JSON
|
|
|
|
import PgQuery
|
|
import RangeQuery
|
|
import Data.Ranged.Ranges (emptyRange)
|
|
import Codec.Binary.Base64.String (decode)
|
|
|
|
-- }}}
|
|
|
|
data AppConfig = AppConfig {
|
|
configDbUri :: String
|
|
, configPort :: Int
|
|
, configSslCert :: FilePath
|
|
, configSslKey :: FilePath
|
|
, configAnonRole :: String
|
|
}
|
|
|
|
jsonContentType :: (HeaderName, BS.ByteString)
|
|
jsonContentType = (hContentType, "application/json")
|
|
|
|
jsonBodyAction :: Request -> (SqlRow -> IO Response) -> IO Response
|
|
jsonBodyAction req handler = do
|
|
parse <- jsonBody req
|
|
case parse of
|
|
Left err -> return $ responseLBS status400 [jsonContentType] json
|
|
where json = JSON.encode . JSON.object $ [("error", JSON.String $ cs err)]
|
|
Right body -> handler body
|
|
|
|
jsonBody :: Request -> IO (Either String SqlRow)
|
|
jsonBody = fmap JSON.eitherDecode . strictRequestBody
|
|
|
|
filterByKeys :: Ord a => Map a b -> [a] -> Map a b
|
|
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 :: DbRole -> Connection -> Application
|
|
app anonymous conn 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 = reportPgErrors (\req respond ->
|
|
let
|
|
path = pathInfo req
|
|
verb = requestMethod req
|
|
qq = queryString req
|
|
hdrs = requestHeaders req
|
|
ver = fromMaybe "1" $ requestedVersion hdrs
|
|
range = requestedRange hdrs
|
|
cRange = requestedContentRange hdrs
|
|
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
|
in respond =<< case (path, verb) of
|
|
([], _) ->
|
|
responseLBS status200 [jsonContentType] <$> printTables ver conn
|
|
|
|
([table], "OPTIONS") ->
|
|
responseLBS status200 [jsonContentType, allOrigins] <$>
|
|
printColumns ver (cs table) conn
|
|
|
|
([table], "GET") ->
|
|
if range == Just emptyRange
|
|
then return $ responseLBS status416 [] "HTTP Range error"
|
|
else do
|
|
r <- respondWithRangedResult <$> getRows ver (cs table) qq range conn
|
|
let canonical = urlEncodeVars $ sort $
|
|
map (join (***) cs) $
|
|
parseSimpleQuery $
|
|
rawQueryString req
|
|
return $ addHeaders [
|
|
("Content-Location",
|
|
"/" <> cs table <> "?" <> cs canonical
|
|
)] r
|
|
|
|
([table], "POST") ->
|
|
jsonBodyAction req (\row -> do
|
|
allvals <- insert ver table row conn
|
|
keys <- primaryKeyColumns ver (cs table) conn
|
|
let params = urlEncodeVars $ map (\t -> (fst t, "eq." <> convert (snd t) :: String)) $ toList $ filterByKeys allvals keys
|
|
return $ responseLBS status201
|
|
[ jsonContentType
|
|
, (hLocation, "/" <> cs table <> "?" <> cs params)
|
|
] ""
|
|
)
|
|
|
|
([table], "PUT") ->
|
|
jsonBodyAction req (\row -> do
|
|
keys <- primaryKeyColumns ver (cs table) conn
|
|
let specifiedKeys = map (cs . fst) qq
|
|
if S.fromList keys /= S.fromList specifiedKeys
|
|
then return $ responseLBS status405 []
|
|
"You must speficy all and only primary keys as params"
|
|
else
|
|
if isJust cRange
|
|
then return $ responseLBS status400 []
|
|
"Content-Range is not allowed in PUT request"
|
|
else do
|
|
cols <- columns ver (cs table) conn
|
|
let colNames = S.fromList $ map (cs . colName) cols
|
|
let specifiedCols = S.fromList $ map fst $ getRow row
|
|
return $ if colNames == specifiedCols then
|
|
responseLBS status200 [ jsonContentType ] ""
|
|
|
|
else if S.null colNames then responseLBS status404 [] ""
|
|
else responseLBS status400 []
|
|
"You must specify all columns in PUT request"
|
|
)
|
|
|
|
(_, _) ->
|
|
return $ responseLBS status404 [] ""
|
|
)
|
|
|
|
defaultCorsPolicy :: CorsResourcePolicy
|
|
defaultCorsPolicy = CorsResourcePolicy Nothing
|
|
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
|
(Just $ 60*60*24) False False True
|
|
|
|
corsPolicy :: Request -> Maybe CorsResourcePolicy
|
|
corsPolicy req = case lookup "origin" headers of
|
|
Just origin -> Just defaultCorsPolicy {
|
|
corsOrigins = Just ([origin], True)
|
|
, corsRequestHeaders = "Authentication":accHeaders
|
|
}
|
|
Nothing -> Nothing
|
|
where
|
|
headers = requestHeaders req
|
|
accHeaders = case lookup "access-control-request-headers" headers of
|
|
Just hdrs -> map (CI.mk . cs . strip . cs) $ BS.split ',' hdrs
|
|
Nothing -> []
|
|
|
|
|
|
respondWithRangedResult :: RangedResult -> Response
|
|
respondWithRangedResult rr =
|
|
responseLBS status [
|
|
jsonContentType,
|
|
("Content-Range",
|
|
if total == 0 || from > total
|
|
then "*/" <> cs (show total)
|
|
else cs (show from) <> "-"
|
|
<> cs (show to) <> "/"
|
|
<> cs (show total)
|
|
)
|
|
] (rrBody rr)
|
|
|
|
where
|
|
from = rrFrom rr
|
|
to = rrTo rr
|
|
total = rrTotal rr
|
|
status
|
|
| from > total = status416
|
|
| total == 0 = status204
|
|
| (1 + to - from) < total = status206
|
|
| otherwise = status200
|
|
|
|
requestedVersion :: RequestHeaders -> Maybe String
|
|
requestedVersion hdrs =
|
|
case verStr of
|
|
Just [[_, ver]] -> Just ver
|
|
_ -> Nothing
|
|
|
|
where verRegex = "version[ ]*=[ ]*([0-9]+)" :: String
|
|
accept = cs <$> lookup hAccept hdrs :: Maybe String
|
|
verStr = (=~ verRegex) <$> accept :: Maybe [[String]]
|
|
|
|
|
|
addHeaders :: ResponseHeaders -> Response -> Response
|
|
addHeaders hdrs (ResponseFile s headers fp m) =
|
|
ResponseFile s (headers ++ hdrs) fp m
|
|
addHeaders hdrs (ResponseBuilder s headers b) =
|
|
ResponseBuilder s (headers ++ hdrs) b
|
|
addHeaders hdrs (ResponseStream s headers b) =
|
|
ResponseStream s (headers ++ hdrs) b
|
|
addHeaders hdrs (ResponseRaw s resp) =
|
|
ResponseRaw s (addHeaders hdrs resp)
|