refactor: App.hs and related changes (#1725)

* Use ExceptT to avoid 'staircasing' case analysis in App.hs
* Split large function in App.hs into individual handler functions
* Adapt API of Auth.hs, OpenApi.hs etc. to simplify the use of those modules in App.hs
* Split optional rollback functionality into Middleware
* Unify SimpleError and ApiRequestError into one Error type, so it can be used across modules
This commit is contained in:
Remo Rechkemmer
2021-02-23 22:41:48 +01:00
committed by GitHub
parent 0ddd676ef0
commit e6973f966b
11 changed files with 734 additions and 567 deletions
+527 -410
View File
@@ -9,430 +9,547 @@ Some of its functionality includes:
- Producing HTTP Headers according to RFCs.
- Content Negotiation
-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.App (postgrest) where
module PostgREST.App (
postgrest
) where
import Control.Monad.Except (liftEither)
import Data.Either.Combinators (mapLeft)
import Data.IORef (IORef, readIORef)
import Data.List (union)
import Data.Time.Clock (UTCTime)
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as M
import qualified Data.List as L (union)
import qualified Data.Set as S
import qualified Hasql.Pool as P
import qualified Hasql.Transaction as H
import qualified Hasql.Transaction as HT
import qualified Hasql.Transaction.Sessions as HT
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Set as Set
import qualified Hasql.DynamicStatements.Snippet as SQL
import qualified Hasql.Pool as SQL
import qualified Hasql.Transaction as SQL
import qualified Hasql.Transaction.Sessions as SQL
import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.HTTP.Types.URI as HTTP
import qualified Network.Wai as Wai
import Data.IORef (IORef, readIORef)
import Data.Time.Clock (UTCTime)
import Network.HTTP.Types.URI (renderSimpleQuery)
import qualified PostgREST.ApiRequest as ApiRequest
import qualified PostgREST.Auth as Auth
import qualified PostgREST.DbRequestBuilder as ReqBuilder
import qualified PostgREST.DbStructure as DbStructure
import qualified PostgREST.Error as Error
import qualified PostgREST.Middleware as Middleware
import qualified PostgREST.OpenAPI as OpenAPI
import qualified PostgREST.QueryBuilder as QueryBuilder
import qualified PostgREST.RangeQuery as RangeQuery
import qualified PostgREST.Statements as Statements
import Control.Applicative
import Data.Maybe
import Network.HTTP.Types.Header
import Network.HTTP.Types.Status
import Network.Wai
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
InvokeMethod (..), Target (..))
import PostgREST.Config (AppConfig (..))
import PostgREST.Error (Error)
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
InvokeMethod (..), Target (..),
mutuallyAgreeable, userApiRequest)
import PostgREST.Auth (attemptJwtClaims, containsRole,
jwtClaims)
import PostgREST.Config (AppConfig (..))
import PostgREST.DbRequestBuilder (mutateRequest, readRequest,
returningCols)
import PostgREST.DbStructure
import PostgREST.Error (PgError (..), SimpleError (..),
errorResponseFor, singularityError)
import PostgREST.Middleware
import PostgREST.OpenAPI
import PostgREST.QueryBuilder (limitedQuery, mutateRequestToQuery,
readRequestToCountQuery,
readRequestToQuery,
requestToCallProcQuery)
import PostgREST.RangeQuery (allRange, contentRangeH,
rangeStatusHeader)
import PostgREST.Statements (callProcStatement,
createExplainStatement,
createReadStatement,
createWriteStatement)
import PostgREST.Types
import Protolude hiding (Proxy, intercalate, toS)
import Protolude.Conv (toS)
postgrest :: LogLevel -> IORef AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application
import Protolude hiding (Handler, toS)
import Protolude.Conv (toS)
data RequestContext = RequestContext
{ ctxConfig :: AppConfig
, ctxDbStructure :: DbStructure
, ctxApiRequest :: ApiRequest
, ctxContentType :: ContentType
}
type Handler = ExceptT Error
type DbHandler = Handler SQL.Transaction
-- | PostgREST application
postgrest
:: LogLevel
-> IORef AppConfig
-> IORef (Maybe DbStructure)
-> SQL.Pool
-> IO UTCTime
-> IO () -- ^ Lauch connection worker in a separate thread
-> Wai.Application
postgrest logLev refConf refDbStructure pool getTime connWorker =
pgrstMiddleware logLev $ \ req respond -> do
time <- getTime
body <- strictRequestBody req
maybeDbStructure <- readIORef refDbStructure
conf <- readIORef refConf
Middleware.pgrstMiddleware logLev $
\req respond -> do
time <- getTime
conf <- readIORef refConf
maybeDbStructure <- readIORef refDbStructure
let
eitherResponse :: IO (Either Error Wai.Response)
eitherResponse =
runExceptT $ postgrestResponse conf maybeDbStructure pool time req
response <- either Error.errorResponseFor identity <$> eitherResponse
-- Launch the connWorker when the connection is down. The postgrest
-- function can respond successfully (with a stale schema cache) before
-- the connWorker is done.
when (Wai.responseStatus response == HTTP.status503) connWorker
respond response
postgrestResponse
:: AppConfig
-> Maybe DbStructure
-> SQL.Pool
-> UTCTime
-> Wai.Request
-> Handler IO Wai.Response
postgrestResponse conf@AppConfig{..} maybeDbStructure pool time req = do
body <- lift $ Wai.strictRequestBody req
dbStructure <-
case maybeDbStructure of
Nothing -> respond . errorResponseFor $ ConnectionLostError
Just dbStructure -> do
response <- do
let apiReq = userApiRequest (configDbSchemas conf) (configDbRootSpec conf) dbStructure req body
case apiReq of
Left err -> return . errorResponseFor $ err
Right apiRequest -> do
-- The jwt must be checked before touching the db.
attempt <- attemptJwtClaims (configJWKS conf) (configJwtAudience conf) (toS $ iJWT apiRequest) time (configJwtRoleClaimKey conf)
case jwtClaims attempt of
Left errJwt -> return . errorResponseFor $ errJwt
Right claims -> do
let
authed = containsRole claims
shouldCommit = configDbTxAllowOverride conf && iPreferTransaction apiRequest == Just Commit
shouldRollback = configDbTxAllowOverride conf && iPreferTransaction apiRequest == Just Rollback
preferenceApplied
| shouldCommit = addHeadersIfNotIncluded [(hPreferenceApplied, BS.pack (show Commit))]
| shouldRollback = addHeadersIfNotIncluded [(hPreferenceApplied, BS.pack (show Rollback))]
| otherwise = identity
handleReq = do
when (shouldRollback || (configDbTxRollbackAll conf && not shouldCommit)) HT.condemn
mapResponseHeaders preferenceApplied <$> runPgLocals conf claims (app dbStructure conf) apiRequest
dbResp <- P.use pool $ HT.transaction HT.ReadCommitted (txMode apiRequest) handleReq
return $ either (errorResponseFor . PgError authed) identity dbResp
-- Launch the connWorker when the connection is down. The postgrest function can respond successfully(with a stale schema cache) before the connWorker is done.
when (responseStatus response == status503) connWorker
respond response
Just dbStructure ->
return dbStructure
Nothing ->
throwError Error.ConnectionLostError
txMode :: ApiRequest -> HT.Mode
txMode apiRequest =
case (iAction apiRequest, iTarget apiRequest) of
(ActionRead _ , _) -> HT.Read
(ActionInfo , _) -> HT.Read
(ActionInspect _ , _) -> HT.Read
(ActionInvoke InvGet , _) -> HT.Read
(ActionInvoke InvHead, _) -> HT.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Stable} _) -> HT.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Immutable} _) -> HT.Read
_ -> HT.Write
apiRequest@ApiRequest{..} <-
liftEither . mapLeft Error.ApiRequestError $
ApiRequest.userApiRequest configDbSchemas configDbRootSpec dbStructure req body
app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure conf apiRequest =
let rawContentTypes = (decodeContentType <$> configRawMediaTypes conf) `L.union` [ CTOctetStream, CTTextPlain ] in
case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of
Left errorResponse -> return errorResponse
Right contentType ->
case (iAction apiRequest, iTarget apiRequest) of
-- The JWT must be checked before touching the db
jwtClaims <- Auth.jwtClaims conf (toS iJWT) time
(ActionRead headersOnly, TargetIdent (QualifiedIdentifier tSchema tName)) ->
case readSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (q, cq, bField, _) -> do
let cQuery = if estimatedCount
then limitedQuery cq ((+ 1) <$> maxRows) -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
else cq
stm = createReadStatement q cQuery (contentType == CTSingularJSON) shouldCount
(contentType == CTTextCSV) bField pgVer prepared
explStm = createExplainStatement cq prepared
row <- H.statement mempty stm
let (tableTotal, queryTotal, _ , body, gucHeaders, gucStatus) = row
gucs = (,) <$> gucHeaders <*> gucStatus
case gucs of
Left err -> return $ errorResponseFor err
Right (ghdrs, gstatus) -> do
total <- if | plannedCount -> H.statement mempty explStm
| estimatedCount -> if tableTotal > (fromIntegral <$> maxRows)
then do estTotal <- H.statement mempty explStm
pure $ if estTotal > tableTotal then estTotal else tableTotal
else pure tableTotal
| otherwise -> pure tableTotal
let (rangeStatus, contentRange) = rangeStatusHeader topLevelRange queryTotal total
status = fromMaybe rangeStatus gstatus
headers = addHeadersIfNotIncluded (catMaybes [
Just $ toHeader contentType, Just contentRange,
Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])
(unwrapGucHeader <$> ghdrs)
rBody = if headersOnly then mempty else toS body
return $
if contentType == CTSingularJSON && queryTotal /= 1
then errorResponseFor . singularityError $ queryTotal
else responseLBS status headers rBody
contentType <-
case ApiRequest.mutuallyAgreeable (requestContentTypes conf apiRequest) iAccepts of
Just ct ->
return ct
Nothing ->
throwError . Error.ContentTypeError $ map toMime iAccepts
(ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName)) ->
case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (sq, mq) -> do
let pkCols = tablePKCols dbStructure tSchema tName
stm = createWriteStatement sq mq
(contentType == CTSingularJSON) True
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols pgVer prepared
row <- H.statement mempty stm
let (_, queryTotal, fields, body, gucHeaders, gucStatus) = row
gucs = (,) <$> gucHeaders <*> gucStatus
case gucs of
Left err -> return $ errorResponseFor err
Right (ghdrs, gstatus) -> do
let
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then ([Just $ toHeader contentType, profileH], toS body)
else ([], mempty)
status = fromMaybe status201 gstatus
headers = addHeadersIfNotIncluded (catMaybes ([
if null fields
then Nothing
else Just $ locationH tName fields
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
, if null pkCols && isNothing (iOnConflict apiRequest)
then Nothing
else (\x -> ("Preference-Applied", BS.pack (show x))) <$> iPreferResolution apiRequest
] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status headers rBody
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName)) ->
case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (sq, mq) -> do
row <- H.statement mempty $
createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) mempty pgVer prepared
let (_, queryTotal, _, body, gucHeaders, gucStatus) = row
gucs = (,) <$> gucHeaders <*> gucStatus
case gucs of
Left err -> return $ errorResponseFor err
Right (ghdrs, gstatus) -> do
let
updateIsNoOp = S.null (iColumns apiRequest)
defStatus | queryTotal == 0 && not updateIsNoOp = status404
| iPreferRepresentation apiRequest == Full = status200
| otherwise = status204
status = fromMaybe defStatus gstatus
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then ([Just $ toHeader contentType, profileH], toS body)
else ([], mempty)
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status headers rBody
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName)) ->
case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (sq, mq) ->
if topLevelRange /= allRange
then return . errorResponseFor $ PutRangeNotAllowedError
else do
row <- H.statement mempty $
createWriteStatement sq mq (contentType == CTSingularJSON) False
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) mempty pgVer prepared
let (_, queryTotal, _, body, gucHeaders, gucStatus) = row
gucs = (,) <$> gucHeaders <*> gucStatus
case gucs of
Left err -> return $ errorResponseFor err
Right (ghdrs, gstatus) -> do
let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)
(defStatus, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
status = fromMaybe defStatus gstatus
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
if queryTotal /= 1
then do
HT.condemn
return . errorResponseFor $ PutMatchingPkError
else
return $ responseLBS status headers rBody
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName)) ->
case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (sq, mq) -> do
let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False
(contentType == CTTextCSV)
(iPreferRepresentation apiRequest) mempty pgVer prepared
row <- H.statement mempty stm
let (_, queryTotal, _, body, gucHeaders, gucStatus) = row
gucs = (,) <$> gucHeaders <*> gucStatus
case gucs of
Left err -> return $ errorResponseFor err
Right (ghdrs, gstatus) -> do
let
defStatus = if iPreferRepresentation apiRequest == Full then status200 else status204
status = fromMaybe defStatus gstatus
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
then ([Just $ toHeader contentType, profileH], toS body)
else ([], mempty)
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
if contentType == CTSingularJSON
&& queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status headers rBody
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable)) ->
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
case mTable of
Nothing -> return notFound
Just table ->
let allowH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET")
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header in
return $ responseLBS status200 [allOrigins, allowH] mempty
(ActionInvoke invMethod, TargetProc proc@ProcDescription{pdSchema, pdName} _) ->
let tName = fromMaybe pdName $ procTableName proc in
case readSqlParts pdSchema tName of
Left errorResponse -> return errorResponse
Right (q, cq, bField, returning) -> do
let
preferParams = iPreferParameters apiRequest
pq = requestToCallProcQuery (QualifiedIdentifier pdSchema pdName) (specifiedProcArgs (iColumns apiRequest) proc)
(iPayload apiRequest) returnsScalar preferParams returning
stm = callProcStatement returnsScalar returnsSingle pq q cq shouldCount (contentType == CTSingularJSON)
(contentType == CTTextCSV) (preferParams == Just MultipleObjects) bField pgVer prepared
row <- H.statement mempty stm
let (tableTotal, queryTotal, body, gucHeaders, gucStatus) = row
gucs = (,) <$> gucHeaders <*> gucStatus
case gucs of
Left err -> return $ errorResponseFor err
Right (ghdrs, gstatus) -> do
let (rangeStatus, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal
status = fromMaybe rangeStatus gstatus
headers = addHeadersIfNotIncluded
(catMaybes [Just $ toHeader contentType, Just contentRange, profileH])
(unwrapGucHeader <$> ghdrs)
rBody = if invMethod == InvHead then mempty else toS body
if contentType == CTSingularJSON && queryTotal /= 1
then do
HT.condemn
return . errorResponseFor . singularityError $ queryTotal
else
return $ responseLBS status headers rBody
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
let host = configServerHost conf
port = toInteger $ configServerPort conf
proxy = pickProxy $ toS <$> configOpenApiServerProxyUri conf
uri Nothing = ("http", host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy
toTableInfo :: [Table] -> [(Table, [Column], [Text])]
toTableInfo = map (\t -> let (s, tn) = (tableSchema t, tableName t) in (t, tableCols dbStructure s tn, tablePKCols dbStructure s tn))
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure
body <- encodeApi <$>
H.statement tSchema (accessibleTables prepared) <*>
H.statement tSchema (schemaDescription prepared) <*>
H.statement tSchema (accessibleProcs prepared)
return $ responseLBS status200 (catMaybes [Just $ toHeader CTOpenAPI, profileH]) (if headersOnly then mempty else toS body)
_ -> return notFound
where
notFound = responseLBS status404 mempty ""
maxRows = configDbMaxRows conf
prepared = configDbPreparedStatements conf
exactCount = iPreferCount apiRequest == Just ExactCount
estimatedCount = iPreferCount apiRequest == Just EstimatedCount
plannedCount = iPreferCount apiRequest == Just PlannedCount
shouldCount = exactCount || estimatedCount
topLevelRange = iTopLevelRange apiRequest
returnsScalar =
case iTarget apiRequest of
TargetProc proc _ -> procReturnsScalar proc
_ -> False
returnsSingle =
case iTarget apiRequest of
TargetProc proc _ -> procReturnsSingle proc
_ -> False
pgVer = pgVersion dbStructure
profileH = contentProfileH <$> iProfile apiRequest
readSqlParts s t =
let
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
returnings :: ReadRequest -> Either Response [FieldName]
returnings rr = Right (returningCols rr [])
in
(,,,) <$>
(readRequestToQuery <$> readReq) <*>
(readRequestToCountQuery <$> readReq) <*>
(binaryField contentType rawContentTypes returnsScalar =<< readReq) <*>
(returnings =<< readReq)
mutateSqlParts s t =
let
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
mutReq = mutateRequest s t apiRequest (tablePKCols dbStructure s t) =<< readReq
in
(,) <$>
(readRequestToQuery <$> readReq) <*>
(mutateRequestToQuery <$> mutReq)
responseContentTypeOrError :: [ContentType] -> [ContentType] -> Action -> Target -> Either Response ContentType
responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts
where
contentTypesForRequest = case action of
ActionRead _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
++ rawContentTypes
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
++ rawContentTypes
++ [CTOpenAPI | tpIsRootSpec target]
ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]
ActionInfo -> [CTTextCSV]
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
serves sProduces cAccepts =
case mutuallyAgreeable sProduces cAccepts of
Nothing -> Left . errorResponseFor . ContentTypeError . map toMime $ cAccepts
Just ct -> Right ct
{-
| If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that
| `?select=...` contains only one field other than `*`
-}
binaryField :: ContentType -> [ContentType] -> Bool -> ReadRequest -> Either Response (Maybe FieldName)
binaryField ct rawContentTypes isScalarProc readReq
| isScalarProc =
if ct `elem` rawContentTypes
then Right $ Just "pgrst_scalar"
else Right Nothing
| ct `elem` rawContentTypes =
let fieldName = headMay fldNames in
if length fldNames == 1 && fieldName /= Just "*"
then Right fieldName
else Left . errorResponseFor $ BinaryFieldError ct
| otherwise = Right Nothing
where
fldNames = fstFieldNames readReq
locationH :: TableName -> [BS.ByteString] -> Header
locationH tName fields =
let
locationFields = renderSimpleQuery True $ splitKeyValue <$> fields
in
(hLocation, "/" <> toS tName <> locationFields)
handleReq apiReq =
handleRequest $ RequestContext conf dbStructure apiReq contentType
runDbHandler pool (txMode apiRequest) jwtClaims .
Middleware.optionalRollback conf apiRequest $
Middleware.runPgLocals conf jwtClaims handleReq apiRequest
runDbHandler :: SQL.Pool -> SQL.Mode -> Auth.JWTClaims -> DbHandler a -> Handler IO a
runDbHandler pool mode jwtClaims handler = do
dbResp <-
lift . SQL.use pool . SQL.transaction SQL.ReadCommitted mode $ runExceptT handler
resp <-
liftEither . mapLeft Error.PgErr $
mapLeft (Error.PgError $ Auth.containsRole jwtClaims) dbResp
liftEither resp
handleRequest :: RequestContext -> DbHandler Wai.Response
handleRequest context@(RequestContext _ _ ApiRequest{..} _) =
case (iAction, iTarget) of
(ActionRead headersOnly, TargetIdent identifier) ->
handleRead headersOnly identifier context
(ActionCreate, TargetIdent identifier) ->
handleCreate identifier context
(ActionUpdate, TargetIdent identifier) ->
handleUpdate identifier context
(ActionSingleUpsert, TargetIdent identifier) ->
handleSingleUpsert identifier context
(ActionDelete, TargetIdent identifier) ->
handleDelete identifier context
(ActionInfo, TargetIdent identifier) ->
handleInfo identifier context
(ActionInvoke invMethod, TargetProc proc _) ->
handleInvoke invMethod proc context
(ActionInspect headersOnly, TargetDefaultSpec tSchema) ->
handleOpenApi headersOnly tSchema context
_ ->
throwError Error.NotFound
handleRead :: Bool -> QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleRead headersOnly identifier context@RequestContext{..} = do
req <- readRequest identifier context
bField <- binaryField context req
let
ApiRequest{..} = ctxApiRequest
AppConfig{..} = ctxConfig
countQuery = QueryBuilder.readRequestToCountQuery req
(tableTotal, queryTotal, _ , body, gucHeaders, gucStatus) <-
lift . SQL.statement mempty $
Statements.createReadStatement
(QueryBuilder.readRequestToQuery req)
(if iPreferCount == Just EstimatedCount then
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
QueryBuilder.limitedQuery countQuery ((+ 1) <$> configDbMaxRows)
else
countQuery
)
(ctxContentType == CTSingularJSON)
(shouldCount iPreferCount)
(ctxContentType == CTTextCSV)
bField
(pgVersion ctxDbStructure)
configDbPreparedStatements
total <- readTotal ctxConfig ctxApiRequest tableTotal countQuery
response <- liftEither $ gucResponse <$> gucStatus <*> gucHeaders
let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange queryTotal total
headers =
[ contentRange
, ( "Content-Location"
, "/"
<> toS (qiName identifier)
<> if BS8.null iCanonicalQS then mempty else "?" <> toS iCanonicalQS
)
]
++ contentTypeHeaders context
failNotSingular ctxContentType queryTotal . response status headers $
if headersOnly then mempty else toS body
readTotal :: AppConfig -> ApiRequest -> Maybe Int64 -> SQL.Snippet -> DbHandler (Maybe Int64)
readTotal AppConfig{..} ApiRequest{..} tableTotal countQuery =
case iPreferCount of
Just PlannedCount ->
explain
Just EstimatedCount ->
if tableTotal > (fromIntegral <$> configDbMaxRows) then
max tableTotal <$> explain
else
return tableTotal
_ ->
return tableTotal
where
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
splitKeyValue kv =
let (k, v) = BS.break (== '=') kv
in (k, BS.tail v)
explain =
lift . SQL.statement mempty . Statements.createExplainStatement countQuery $
configDbPreparedStatements
contentLocationH :: TableName -> ByteString -> Header
contentLocationH tName qString =
("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
handleCreate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleCreate identifier@QualifiedIdentifier{..} context@RequestContext{..} = do
let
ApiRequest{..} = ctxApiRequest
pkCols = tablePKCols ctxDbStructure qiSchema qiName
contentProfileH :: Schema -> Header
contentProfileH schema =
("Content-Profile", toS schema)
WriteQueryResult{..} <- writeQuery identifier True pkCols context
let
response = gucResponse resGucStatus resGucHeaders
headers =
catMaybes
[ if null resFields then
Nothing
else
Just
( HTTP.hLocation
, "/"
<> toS qiName
<> HTTP.renderSimpleQuery True (splitKeyValue <$> resFields)
)
, Just . RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just resQueryTotal else Nothing
, if null pkCols && isNothing iOnConflict then
Nothing
else
(\x -> ("Preference-Applied", BS8.pack $ show x)) <$> iPreferResolution
]
failNotSingular ctxContentType resQueryTotal $
if iPreferRepresentation == Full then
response HTTP.status201 (headers ++ contentTypeHeaders context) (toS resBody)
else
response HTTP.status201 headers mempty
handleUpdate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleUpdate identifier context@(RequestContext _ _ ApiRequest{..} contentType) = do
WriteQueryResult{..} <- writeQuery identifier False mempty context
let
response = gucResponse resGucStatus resGucHeaders
fullRepr = iPreferRepresentation == Full
updateIsNoOp = Set.null iColumns
status
| resQueryTotal == 0 && not updateIsNoOp = HTTP.status404
| fullRepr = HTTP.status200
| otherwise = HTTP.status204
contentRangeHeader =
RangeQuery.contentRangeH 0 (resQueryTotal - 1) $
if shouldCount iPreferCount then Just resQueryTotal else Nothing
failNotSingular contentType resQueryTotal $
if fullRepr then
response status (contentTypeHeaders context ++ [contentRangeHeader]) (toS resBody)
else
response status [contentRangeHeader] mempty
handleSingleUpsert :: QualifiedIdentifier -> RequestContext-> DbHandler Wai.Response
handleSingleUpsert identifier context@(RequestContext _ _ ApiRequest{..} _) = do
when (iTopLevelRange /= RangeQuery.allRange) $
throwError Error.PutRangeNotAllowedError
WriteQueryResult{..} <- writeQuery identifier False mempty context
let response = gucResponse resGucStatus resGucHeaders
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
-- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.
-- If this condition is not satisfied then nothing is inserted,
-- check the WHERE for INSERT in QueryBuilder.hs to see how it's done
when (resQueryTotal /= 1) $ do
lift SQL.condemn
throwError Error.PutMatchingPkError
return $
if iPreferRepresentation == Full then
response HTTP.status200 (contentTypeHeaders context) (toS resBody)
else
response HTTP.status204 (contentTypeHeaders context) mempty
handleDelete :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response
handleDelete identifier context@(RequestContext _ _ ApiRequest{..} contentType) = do
WriteQueryResult{..} <- writeQuery identifier False mempty context
let
response = gucResponse resGucStatus resGucHeaders
contentRangeHeader =
RangeQuery.contentRangeH 1 0 $
if shouldCount iPreferCount then Just resQueryTotal else Nothing
failNotSingular contentType resQueryTotal $
if iPreferRepresentation == Full then
response HTTP.status200
(contentTypeHeaders context ++ [contentRangeHeader])
(toS resBody)
else
response HTTP.status204 [contentRangeHeader] mempty
handleInfo :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m Wai.Response
handleInfo identifier RequestContext{..} =
case find tableMatches $ dbTables ctxDbStructure of
Just table ->
return $ Wai.responseLBS HTTP.status200 [allOrigins, allowH table] mempty
Nothing ->
throwError Error.NotFound
where
allOrigins = ("Access-Control-Allow-Origin", "*")
allowH table =
( HTTP.hAllow
, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET"
)
tableMatches table =
tableName table == qiName identifier
&& tableSchema table == qiSchema identifier
handleInvoke :: InvokeMethod -> ProcDescription -> RequestContext -> DbHandler Wai.Response
handleInvoke invMethod proc context@RequestContext{..} = do
let
ApiRequest{..} = ctxApiRequest
identifier =
QualifiedIdentifier
(pdSchema proc)
(fromMaybe (pdName proc) $ procTableName proc)
returnsSingle (ApiRequest.TargetProc target _) = procReturnsSingle target
returnsSingle _ = False
req <- readRequest identifier context
bField <- binaryField context req
(tableTotal, queryTotal, body, gucHeaders, gucStatus) <-
lift . SQL.statement mempty $
Statements.callProcStatement
(returnsScalar iTarget)
(returnsSingle iTarget)
(QueryBuilder.requestToCallProcQuery
(QualifiedIdentifier (pdSchema proc) (pdName proc))
(specifiedProcArgs iColumns proc)
iPayload
(returnsScalar iTarget)
iPreferParameters
(ReqBuilder.returningCols req [])
)
(QueryBuilder.readRequestToQuery req)
(QueryBuilder.readRequestToCountQuery req)
(shouldCount iPreferCount)
(ctxContentType == CTSingularJSON)
(ctxContentType == CTTextCSV)
(iPreferParameters == Just MultipleObjects)
bField
(pgVersion ctxDbStructure)
(configDbPreparedStatements ctxConfig)
response <- liftEither $ gucResponse <$> gucStatus <*> gucHeaders
let
(status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange queryTotal tableTotal
failNotSingular ctxContentType queryTotal $
response status
(contentTypeHeaders context ++ [contentRange])
(if invMethod == InvHead then mempty else toS body)
handleOpenApi :: Bool -> Schema -> RequestContext -> DbHandler Wai.Response
handleOpenApi headersOnly tSchema (RequestContext conf@AppConfig{..} dbStructure apiRequest _) = do
body <-
lift $
OpenAPI.encode conf dbStructure
<$> SQL.statement tSchema (DbStructure.accessibleTables configDbPreparedStatements)
<*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
<*> SQL.statement tSchema (DbStructure.accessibleProcs configDbPreparedStatements)
return $
Wai.responseLBS HTTP.status200
(toHeader CTOpenAPI : maybeToList (profileHeader apiRequest))
(if headersOnly then mempty else toS body)
txMode :: ApiRequest -> SQL.Mode
txMode ApiRequest{..} =
case (iAction, iTarget) of
(ActionRead _, _) ->
SQL.Read
(ActionInfo, _) ->
SQL.Read
(ActionInspect _, _) ->
SQL.Read
(ActionInvoke InvGet, _) ->
SQL.Read
(ActionInvoke InvHead, _) ->
SQL.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Stable} _) ->
SQL.Read
(ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Immutable} _) ->
SQL.Read
_ ->
SQL.Write
-- | Result from executing a write query on the database
data WriteQueryResult = WriteQueryResult
{ resQueryTotal :: Int64
, resFields :: [ByteString]
, resBody :: ByteString
, resGucStatus :: Maybe HTTP.Status
, resGucHeaders :: [GucHeader]
}
writeQuery :: QualifiedIdentifier -> Bool -> [Text] -> RequestContext -> DbHandler WriteQueryResult
writeQuery identifier@QualifiedIdentifier{..} isInsert pkCols context@RequestContext{..} = do
readReq <- readRequest identifier context
mutateReq <-
liftEither $
ReqBuilder.mutateRequest qiSchema qiName ctxApiRequest
(tablePKCols ctxDbStructure qiSchema qiName)
readReq
(_, queryTotal, fields, body, gucHeaders, gucStatus) <-
lift . SQL.statement mempty $
Statements.createWriteStatement
(QueryBuilder.readRequestToQuery readReq)
(QueryBuilder.mutateRequestToQuery mutateReq)
(ctxContentType == CTSingularJSON)
isInsert
(ctxContentType == CTTextCSV)
(iPreferRepresentation ctxApiRequest)
pkCols
(pgVersion ctxDbStructure)
(configDbPreparedStatements ctxConfig)
liftEither $ WriteQueryResult queryTotal fields body <$> gucStatus <*> gucHeaders
-- | Response with headers and status overridden from GUCs.
gucResponse
:: Maybe HTTP.Status
-> [GucHeader]
-> HTTP.Status
-> [HTTP.Header]
-> LBS.ByteString
-> Wai.Response
gucResponse gucStatus gucHeaders status headers =
Wai.responseLBS (fromMaybe status gucStatus) $
addHeadersIfNotIncluded headers (map unwrapGucHeader gucHeaders)
-- |
-- Fail a response if a single JSON object was requested and not exactly one
-- was found.
failNotSingular :: ContentType -> Int64 -> Wai.Response -> DbHandler Wai.Response
failNotSingular contentType queryTotal response =
if contentType == CTSingularJSON && queryTotal /= 1 then
do
lift SQL.condemn
throwError $ Error.singularityError queryTotal
else
return response
shouldCount :: Maybe PreferCount -> Bool
shouldCount preferCount =
preferCount == Just ExactCount || preferCount == Just EstimatedCount
returnsScalar :: ApiRequest.Target -> Bool
returnsScalar (TargetProc proc _) = procReturnsScalar proc
returnsScalar _ = False
readRequest :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m ReadRequest
readRequest QualifiedIdentifier{..} (RequestContext AppConfig{..} dbStructure apiRequest _) =
liftEither $
ReqBuilder.readRequest qiSchema qiName configDbMaxRows
(dbRelations dbStructure)
apiRequest
contentTypeHeaders :: RequestContext -> [HTTP.Header]
contentTypeHeaders RequestContext{..} =
toHeader ctxContentType : maybeToList (profileHeader ctxApiRequest)
requestContentTypes :: AppConfig -> ApiRequest -> [ContentType]
requestContentTypes conf ApiRequest{..} =
case iAction of
ActionRead _ -> defaultContentTypes ++ rawContentTypes conf
ActionInvoke _ -> invokeContentTypes
ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]
ActionInfo -> [CTTextCSV]
_ -> defaultContentTypes
where
invokeContentTypes =
defaultContentTypes
++ rawContentTypes conf
++ [CTOpenAPI | ApiRequest.tpIsRootSpec iTarget]
defaultContentTypes =
[CTApplicationJSON, CTSingularJSON, CTTextCSV]
-- |
-- If raw(binary) output is requested, check that ContentType is one of the admitted
-- rawContentTypes and that`?select=...` contains only one field other than `*`
binaryField :: Monad m => RequestContext -> ReadRequest -> Handler m (Maybe FieldName)
binaryField RequestContext{..} readReq
| returnsScalar (iTarget ctxApiRequest) && ctxContentType `elem` rawContentTypes ctxConfig =
return $ Just "pgrst_scalar"
| ctxContentType `elem` rawContentTypes ctxConfig =
let
fldNames = fstFieldNames readReq
fieldName = headMay fldNames
in
if length fldNames == 1 && fieldName /= Just "*" then
return fieldName
else
throwError $ Error.BinaryFieldError ctxContentType
| otherwise =
return Nothing
rawContentTypes :: AppConfig -> [ContentType]
rawContentTypes AppConfig{..} =
(decodeContentType <$> configRawMediaTypes) `union` [CTOctetStream, CTTextPlain]
profileHeader :: ApiRequest -> Maybe HTTP.Header
profileHeader ApiRequest{..} =
(,) "Content-Profile" <$> (toS <$> iProfile)
splitKeyValue :: ByteString -> (ByteString, ByteString)
splitKeyValue kv =
(k, BS8.tail v)
where
(k, v) = BS8.break (== '=') kv