Allow multiple schemas to be exposed in one instance (#1450)
The schema to use can be selected through the headers `Accept-Profile` for GET/HEAD and `Content-Profile` for POST/PATCH/PUT/DELETE. This is based on the https://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/ spec. Also increase all memory tests by 1M(otherwise CI fails). Co-authored-by: Mahmoud Kassem <MKassem@gk-software.com> Co-authored-by: Mahmoud Kassem <mahmoud_k@mail.com>
This commit is contained in:
co-authored by
Mahmoud Kassem
Mahmoud Kassem
parent
a80eb2ff0e
commit
691bb5640d
@@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- #1435, Add `request.method` and `request.path` GUCs - @steve-chavez
|
- #1435, Add `request.method` and `request.path` GUCs - @steve-chavez
|
||||||
- #1088, Allow adding headers to GET/POST/PATCH/PUT/DELETE responses through the `response.headers` GUC - @steve-chavez
|
- #1088, Allow adding headers to GET/POST/PATCH/PUT/DELETE responses through the `response.headers` GUC - @steve-chavez
|
||||||
- #1427, Allow overriding provided headers(Location, Content-Type, etc) through the `response.headers` GUC - @steve-chavez
|
- #1427, Allow overriding provided headers(Location, Content-Type, etc) through the `response.headers` GUC - @steve-chavez
|
||||||
|
- #1450, Allow multiple schemas to be exposed in one instance. The schema to use can be selected through the headers `Accept-Profile` for GET/HEAD and `Content-Profile` for POST/PATCH/PUT/DELETE - @steve-chavez, @mahmoudkassem
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -45,7 +45,7 @@ import PostgREST.OpenAPI (isMalformedProxyUri)
|
|||||||
import PostgREST.Types (ConnectionStatus (..), DbStructure,
|
import PostgREST.Types (ConnectionStatus (..), DbStructure,
|
||||||
PgVersion (..), Schema,
|
PgVersion (..), Schema,
|
||||||
minimumPgVersion)
|
minimumPgVersion)
|
||||||
import Protolude hiding (hPutStrLn, replace)
|
import Protolude hiding (hPutStrLn, head, replace)
|
||||||
|
|
||||||
|
|
||||||
#ifndef mingw32_HOST_OS
|
#ifndef mingw32_HOST_OS
|
||||||
@@ -73,11 +73,11 @@ import System.Posix.Signals
|
|||||||
connectionWorker
|
connectionWorker
|
||||||
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
||||||
-> P.Pool -- ^ The PostgreSQL connection pool
|
-> P.Pool -- ^ The PostgreSQL connection pool
|
||||||
-> Schema -- ^ Schema PostgREST is serving up
|
-> [Schema] -- ^ Schemas PostgREST is serving up
|
||||||
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
||||||
-> IORef Bool -- ^ Used as a binary Semaphore
|
-> IORef Bool -- ^ Used as a binary Semaphore
|
||||||
-> IO ()
|
-> IO ()
|
||||||
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do
|
||||||
isWorkerOn <- readIORef refIsWorkerOn
|
isWorkerOn <- readIORef refIsWorkerOn
|
||||||
unless isWorkerOn $ do
|
unless isWorkerOn $ do
|
||||||
atomicWriteIORef refIsWorkerOn True
|
atomicWriteIORef refIsWorkerOn True
|
||||||
@@ -93,7 +93,7 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
|||||||
NotConnected -> return () -- Unreachable
|
NotConnected -> return () -- Unreachable
|
||||||
Connected actualPgVersion -> do -- Procede with initialization
|
Connected actualPgVersion -> do -- Procede with initialization
|
||||||
result <- P.use pool $ do
|
result <- P.use pool $ do
|
||||||
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
|
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schemas actualPgVersion
|
||||||
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
|
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
|
||||||
case result of
|
case result of
|
||||||
Left e -> do
|
Left e -> do
|
||||||
@@ -162,7 +162,8 @@ main = do
|
|||||||
-- readOptions builds the 'AppConfig' from the config file specified on the
|
-- readOptions builds the 'AppConfig' from the config file specified on the
|
||||||
-- command line
|
-- command line
|
||||||
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
|
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
|
||||||
let host = configHost conf
|
let schemas = toList $ configSchemas conf
|
||||||
|
host = configHost conf
|
||||||
port = configPort conf
|
port = configPort conf
|
||||||
proxy = configOpenAPIProxyUri conf
|
proxy = configOpenAPIProxyUri conf
|
||||||
maybeSocketAddr = configSocket conf
|
maybeSocketAddr = configSocket conf
|
||||||
@@ -175,6 +176,7 @@ main = do
|
|||||||
. setServerName (toS $ "postgrest/" <> prettyVersion) $
|
. setServerName (toS $ "postgrest/" <> prettyVersion) $
|
||||||
defaultSettings
|
defaultSettings
|
||||||
|
|
||||||
|
|
||||||
whenLeft socketFileMode panic
|
whenLeft socketFileMode panic
|
||||||
|
|
||||||
-- Checks that the provided proxy uri is formated correctly
|
-- Checks that the provided proxy uri is formated correctly
|
||||||
@@ -205,7 +207,7 @@ main = do
|
|||||||
connectionWorker
|
connectionWorker
|
||||||
mainTid
|
mainTid
|
||||||
pool
|
pool
|
||||||
(configSchema conf)
|
schemas
|
||||||
refDbStructure
|
refDbStructure
|
||||||
refIsWorkerOn
|
refIsWorkerOn
|
||||||
--
|
--
|
||||||
@@ -227,7 +229,7 @@ main = do
|
|||||||
Catch $ connectionWorker
|
Catch $ connectionWorker
|
||||||
mainTid
|
mainTid
|
||||||
pool
|
pool
|
||||||
(configSchema conf)
|
schemas
|
||||||
refDbStructure
|
refDbStructure
|
||||||
refIsWorkerOn
|
refIsWorkerOn
|
||||||
) Nothing
|
) Nothing
|
||||||
@@ -246,7 +248,7 @@ main = do
|
|||||||
(connectionWorker
|
(connectionWorker
|
||||||
mainTid
|
mainTid
|
||||||
pool
|
pool
|
||||||
(configSchema conf)
|
schemas
|
||||||
refDbStructure
|
refDbStructure
|
||||||
refIsWorkerOn)
|
refIsWorkerOn)
|
||||||
in case maybeSocketAddr of
|
in case maybeSocketAddr of
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ test-suite spec
|
|||||||
Feature.UpsertSpec
|
Feature.UpsertSpec
|
||||||
Feature.RawOutputTypesSpec
|
Feature.RawOutputTypesSpec
|
||||||
Feature.HtmlRawOutputSpec
|
Feature.HtmlRawOutputSpec
|
||||||
|
Feature.MultipleSchemaSpec
|
||||||
SpecHelper
|
SpecHelper
|
||||||
TestTypes
|
TestTypes
|
||||||
hs-source-dirs: test
|
hs-source-dirs: test
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import qualified Data.Vector as V
|
|||||||
|
|
||||||
import Control.Arrow ((***))
|
import Control.Arrow ((***))
|
||||||
import Data.Aeson.Types (emptyArray, emptyObject)
|
import Data.Aeson.Types (emptyArray, emptyObject)
|
||||||
import Data.List (last, lookup, partition)
|
import Data.List (elem, last, lookup, partition)
|
||||||
|
import Data.List.NonEmpty (NonEmpty, head)
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Ranged.Ranges (Range (..), emptyRange,
|
import Data.Ranged.Ranges (Range (..), emptyRange,
|
||||||
rangeIntersection)
|
rangeIntersection)
|
||||||
@@ -48,7 +49,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
|
|||||||
rangeLimit, rangeOffset, rangeRequested,
|
rangeLimit, rangeOffset, rangeRequested,
|
||||||
restrictRange)
|
restrictRange)
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
import Protolude
|
import Protolude hiding (head)
|
||||||
|
|
||||||
type RequestBody = BL.ByteString
|
type RequestBody = BL.ByteString
|
||||||
|
|
||||||
@@ -96,11 +97,14 @@ data ApiRequest = ApiRequest {
|
|||||||
, iCookies :: [(Text, Text)] -- ^ Request Cookies
|
, iCookies :: [(Text, Text)] -- ^ Request Cookies
|
||||||
, iPath :: ByteString -- ^ Raw request path
|
, iPath :: ByteString -- ^ Raw request path
|
||||||
, iMethod :: ByteString -- ^ Raw request method
|
, iMethod :: ByteString -- ^ Raw request method
|
||||||
|
, iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.
|
||||||
|
, iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Examines HTTP request and translates it into user intent.
|
-- | Examines HTTP request and translates it into user intent.
|
||||||
userApiRequest :: Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||||
userApiRequest schema rootSpec req reqBody
|
userApiRequest confSchemas rootSpec req reqBody
|
||||||
|
| isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas
|
||||||
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
|
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
|
||||||
| topLevelRange == emptyRange = Left InvalidRange
|
| topLevelRange == emptyRange = Left InvalidRange
|
||||||
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
|
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
|
||||||
@@ -137,6 +141,8 @@ userApiRequest schema rootSpec req reqBody
|
|||||||
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
|
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
|
||||||
, iPath = rawPathInfo req
|
, iPath = rawPathInfo req
|
||||||
, iMethod = method
|
, iMethod = method
|
||||||
|
, iProfile = profile
|
||||||
|
, iSchema = schema
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
-- queryString with '+' converted to ' '(space)
|
-- queryString with '+' converted to ' '(space)
|
||||||
@@ -208,6 +214,18 @@ userApiRequest schema rootSpec req reqBody
|
|||||||
"DELETE" -> ActionDelete
|
"DELETE" -> ActionDelete
|
||||||
"OPTIONS" -> ActionInfo
|
"OPTIONS" -> ActionInfo
|
||||||
_ -> ActionInspect{isHead=False}
|
_ -> ActionInspect{isHead=False}
|
||||||
|
|
||||||
|
defaultSchema = head confSchemas
|
||||||
|
profile
|
||||||
|
| length confSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config
|
||||||
|
= Nothing
|
||||||
|
| action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionDelete] -- POST/PATCH/PUT/DELETE don't use the same header as per the spec
|
||||||
|
= Just $ maybe defaultSchema toS $ lookupHeader "Content-Profile"
|
||||||
|
| action `elem` [ActionRead True, ActionRead False, ActionInvoke InvGet, ActionInvoke InvHead, ActionInvoke InvPost,
|
||||||
|
ActionInspect False, ActionInspect True, ActionInfo]
|
||||||
|
= Just $ maybe defaultSchema toS $ lookupHeader "Accept-Profile"
|
||||||
|
| otherwise = Nothing
|
||||||
|
schema = fromMaybe defaultSchema profile
|
||||||
target = case path of
|
target = case path of
|
||||||
[] -> case rootSpec of
|
[] -> case rootSpec of
|
||||||
Just pName -> TargetProc (QualifiedIdentifier schema pName) True
|
Just pName -> TargetProc (QualifiedIdentifier schema pName) True
|
||||||
|
|||||||
+29
-21
@@ -78,8 +78,9 @@ postgrest conf refDbStructure pool getTime worker =
|
|||||||
Nothing -> respond . errorResponseFor $ ConnectionLostError
|
Nothing -> respond . errorResponseFor $ ConnectionLostError
|
||||||
Just dbStructure -> do
|
Just dbStructure -> do
|
||||||
response <- do
|
response <- do
|
||||||
-- Need to parse ?columns early because findProc needs it to solve overloaded functions
|
-- Need to parse ?columns early because findProc needs it to solve overloaded functions.
|
||||||
let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body
|
-- TODO: move this logic to the app function
|
||||||
|
let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body
|
||||||
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
|
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
|
||||||
case apiReqCols of
|
case apiReqCols of
|
||||||
Left err -> return . errorResponseFor $ err
|
Left err -> return . errorResponseFor $ err
|
||||||
@@ -145,8 +146,9 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
else pure tableTotal
|
else pure tableTotal
|
||||||
| otherwise -> pure tableTotal
|
| otherwise -> pure tableTotal
|
||||||
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
|
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
|
||||||
headers = addHeadersIfNotIncluded
|
headers = addHeadersIfNotIncluded (catMaybes [
|
||||||
[toHeader contentType, contentRange, contentLocationH tName (iCanonicalQS apiRequest)]
|
Just $ toHeader contentType, Just contentRange,
|
||||||
|
Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])
|
||||||
(unwrapGucHeader <$> ghdrs)
|
(unwrapGucHeader <$> ghdrs)
|
||||||
rBody = if headersOnly then mempty else toS body
|
rBody = if headersOnly then mempty else toS body
|
||||||
return $
|
return $
|
||||||
@@ -168,19 +170,18 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
Right ghdrs -> do
|
Right ghdrs -> do
|
||||||
let
|
let
|
||||||
(ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
|
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||||
then (Just $ toHeader contentType, toS body)
|
then ([Just $ toHeader contentType, profileH], toS body)
|
||||||
else (Nothing, mempty)
|
else ([], mempty)
|
||||||
headers = addHeadersIfNotIncluded (catMaybes [
|
headers = addHeadersIfNotIncluded (catMaybes ([
|
||||||
if null fields
|
if null fields
|
||||||
then Nothing
|
then Nothing
|
||||||
else Just $ locationH tName fields
|
else Just $ locationH tName fields
|
||||||
, ctHeader
|
|
||||||
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||||
, if null pkCols && isNothing (iOnConflict apiRequest)
|
, if null pkCols && isNothing (iOnConflict apiRequest)
|
||||||
then Nothing
|
then Nothing
|
||||||
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
|
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
|
||||||
]) (unwrapGucHeader <$> ghdrs)
|
] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
|
||||||
if contentType == CTSingularJSON && queryTotal /= 1
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
then do
|
then do
|
||||||
HT.condemn
|
HT.condemn
|
||||||
@@ -206,10 +207,10 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
| iPreferRepresentation apiRequest == Full = status200
|
| iPreferRepresentation apiRequest == Full = status200
|
||||||
| otherwise = status204
|
| otherwise = status204
|
||||||
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
|
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
|
||||||
(ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
|
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||||
then (Just $ toHeader contentType, toS body)
|
then ([Just $ toHeader contentType, profileH], toS body)
|
||||||
else (Nothing, mempty)
|
else ([], mempty)
|
||||||
headers = addHeadersIfNotIncluded (catMaybes [Just contentRangeHeader, ctHeader]) (unwrapGucHeader <$> ghdrs)
|
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||||
if contentType == CTSingularJSON && queryTotal /= 1
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
then do
|
then do
|
||||||
HT.condemn
|
HT.condemn
|
||||||
@@ -239,7 +240,7 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
case gucHeaders of
|
case gucHeaders of
|
||||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
Right ghdrs -> do
|
Right ghdrs -> do
|
||||||
let headers = addHeadersIfNotIncluded [toHeader contentType] (unwrapGucHeader <$> ghdrs)
|
let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)
|
||||||
(status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
|
(status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
|
||||||
-- Makes sure the querystring pk matches the payload pk
|
-- 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
|
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
|
||||||
@@ -267,10 +268,10 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
let
|
let
|
||||||
status = if iPreferRepresentation apiRequest == Full then status200 else status204
|
status = if iPreferRepresentation apiRequest == Full then status200 else status204
|
||||||
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||||
(ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
|
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||||
then (Just $ toHeader contentType, toS body)
|
then ([Just $ toHeader contentType, profileH], toS body)
|
||||||
else (Nothing, mempty)
|
else ([], mempty)
|
||||||
headers = addHeadersIfNotIncluded (catMaybes [Just contentRangeHeader, ctHeader]) (unwrapGucHeader <$> ghdrs)
|
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||||
if contentType == CTSingularJSON
|
if contentType == CTSingularJSON
|
||||||
&& queryTotal /= 1
|
&& queryTotal /= 1
|
||||||
then do
|
then do
|
||||||
@@ -305,7 +306,9 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
Right ghdrs -> do
|
Right ghdrs -> do
|
||||||
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal
|
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal
|
||||||
headers = addHeadersIfNotIncluded [toHeader contentType, contentRange] (unwrapGucHeader <$> ghdrs)
|
headers = addHeadersIfNotIncluded
|
||||||
|
(catMaybes [Just $ toHeader contentType, Just contentRange, profileH])
|
||||||
|
(unwrapGucHeader <$> ghdrs)
|
||||||
rBody = if invMethod == InvHead then mempty else toS body
|
rBody = if invMethod == InvHead then mempty else toS body
|
||||||
if contentType == CTSingularJSON && queryTotal /= 1
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
then do
|
then do
|
||||||
@@ -329,7 +332,7 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
H.statement tSchema accessibleTables <*>
|
H.statement tSchema accessibleTables <*>
|
||||||
H.statement tSchema schemaDescription <*>
|
H.statement tSchema schemaDescription <*>
|
||||||
H.statement tSchema accessibleProcs
|
H.statement tSchema accessibleProcs
|
||||||
return $ responseLBS status200 [toHeader CTOpenAPI] (if headersOnly then mempty else toS body)
|
return $ responseLBS status200 (catMaybes [Just $ toHeader CTOpenAPI, profileH]) (if headersOnly then mempty else toS body)
|
||||||
|
|
||||||
_ -> return notFound
|
_ -> return notFound
|
||||||
|
|
||||||
@@ -343,6 +346,7 @@ app dbStructure proc cols conf apiRequest =
|
|||||||
topLevelRange = iTopLevelRange apiRequest
|
topLevelRange = iTopLevelRange apiRequest
|
||||||
returnsScalar = maybe False procReturnsScalar proc
|
returnsScalar = maybe False procReturnsScalar proc
|
||||||
pgVer = pgVersion dbStructure
|
pgVer = pgVersion dbStructure
|
||||||
|
profileH = contentProfileH <$> iProfile apiRequest
|
||||||
|
|
||||||
readSqlParts s t =
|
readSqlParts s t =
|
||||||
let
|
let
|
||||||
@@ -413,3 +417,7 @@ locationH tName fields =
|
|||||||
contentLocationH :: TableName -> ByteString -> Header
|
contentLocationH :: TableName -> ByteString -> Header
|
||||||
contentLocationH tName qString =
|
contentLocationH tName qString =
|
||||||
("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
|
("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
|
||||||
|
|
||||||
|
contentProfileH :: Schema -> Header
|
||||||
|
contentProfileH schema =
|
||||||
|
("Content-Profile", toS schema)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import Control.Lens (preview)
|
|||||||
import Control.Monad (fail)
|
import Control.Monad (fail)
|
||||||
import Crypto.JWT (StringOrURI, stringOrUri)
|
import Crypto.JWT (StringOrURI, stringOrUri)
|
||||||
import Data.List (lookup)
|
import Data.List (lookup)
|
||||||
|
import Data.List.NonEmpty (NonEmpty, fromList)
|
||||||
import Data.Scientific (floatingOrInteger)
|
import Data.Scientific (floatingOrInteger)
|
||||||
import Data.Text (dropEnd, dropWhileEnd,
|
import Data.Text (dropEnd, dropWhileEnd,
|
||||||
intercalate, lines, splitOn,
|
intercalate, lines, splitOn,
|
||||||
@@ -71,7 +72,7 @@ data AppConfig = AppConfig {
|
|||||||
configDatabase :: Text
|
configDatabase :: Text
|
||||||
, configAnonRole :: Text
|
, configAnonRole :: Text
|
||||||
, configOpenAPIProxyUri :: Maybe Text
|
, configOpenAPIProxyUri :: Maybe Text
|
||||||
, configSchema :: Text
|
, configSchemas :: NonEmpty Text
|
||||||
, configHost :: Text
|
, configHost :: Text
|
||||||
, configPort :: Int
|
, configPort :: Int
|
||||||
, configSocket :: Maybe Text
|
, configSocket :: Maybe Text
|
||||||
@@ -154,8 +155,8 @@ readOptions = do
|
|||||||
AppConfig
|
AppConfig
|
||||||
<$> reqString "db-uri"
|
<$> reqString "db-uri"
|
||||||
<*> reqString "db-anon-role"
|
<*> reqString "db-anon-role"
|
||||||
<*> optString "openapi-server-proxy-uri"
|
<*> optString "server-proxy-uri"
|
||||||
<*> reqString "db-schema"
|
<*> (fromList . splitOnCommas <$> reqValue "db-schema")
|
||||||
<*> (fromMaybe "!4" <$> optString "server-host")
|
<*> (fromMaybe "!4" <$> optString "server-host")
|
||||||
<*> (fromMaybe 3000 <$> optInt "server-port")
|
<*> (fromMaybe 3000 <$> optInt "server-port")
|
||||||
<*> optString "server-unix-socket"
|
<*> optString "server-unix-socket"
|
||||||
@@ -199,6 +200,9 @@ readOptions = do
|
|||||||
reqString :: C.Key -> C.Parser C.Config Text
|
reqString :: C.Key -> C.Parser C.Config Text
|
||||||
reqString k = C.required k C.string
|
reqString k = C.required k C.string
|
||||||
|
|
||||||
|
reqValue :: C.Key -> C.Parser C.Config C.Value
|
||||||
|
reqValue k = C.required k C.value
|
||||||
|
|
||||||
optString :: C.Key -> C.Parser C.Config (Maybe Text)
|
optString :: C.Key -> C.Parser C.Config (Maybe Text)
|
||||||
optString k = mfilter (/= "") <$> C.optional k C.string
|
optString k = mfilter (/= "") <$> C.optional k C.string
|
||||||
|
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ import PostgREST.Private.Common
|
|||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
|
getDbStructure :: [Schema] -> PgVersion -> HT.Transaction DbStructure
|
||||||
getDbStructure schema pgVer = do
|
getDbStructure schemas pgVer = do
|
||||||
HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
|
HT.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
|
||||||
tabs <- HT.statement () allTables
|
tabs <- HT.statement () allTables
|
||||||
cols <- HT.statement schema $ allColumns tabs
|
cols <- HT.statement schemas $ allColumns tabs
|
||||||
srcCols <- HT.statement schema $ allSourceColumns cols pgVer
|
srcCols <- HT.statement schemas $ allSourceColumns cols pgVer
|
||||||
m2oRels <- HT.statement () $ allM2ORels tabs cols
|
m2oRels <- HT.statement () $ allM2ORels tabs cols
|
||||||
keys <- HT.statement () $ allPrimaryKeys tabs
|
keys <- HT.statement () $ allPrimaryKeys tabs
|
||||||
procs <- HT.statement schema allProcs
|
procs <- HT.statement schemas allProcs
|
||||||
|
|
||||||
let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels
|
let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels
|
||||||
cols' = addForeignKeys rels cols
|
cols' = addForeignKeys rels cols
|
||||||
@@ -126,13 +126,14 @@ sourceColumnFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
|
|||||||
col2 = findCol s2 t2 c2
|
col2 = findCol s2 t2 c2
|
||||||
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
|
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
|
||||||
|
|
||||||
decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
|
decodeProcs :: HD.Result ProcsMap
|
||||||
decodeProcs =
|
decodeProcs =
|
||||||
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
|
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
|
||||||
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
|
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addKey) <$> HD.rowList procRow
|
||||||
where
|
where
|
||||||
tblRow = ProcDescription
|
procRow = ProcDescription
|
||||||
<$> column HD.text
|
<$> column HD.text
|
||||||
|
<*> column HD.text
|
||||||
<*> nullableColumn HD.text
|
<*> nullableColumn HD.text
|
||||||
<*> (parseArgs <$> column HD.text)
|
<*> (parseArgs <$> column HD.text)
|
||||||
<*> (parseRetType
|
<*> (parseRetType
|
||||||
@@ -142,8 +143,8 @@ decodeProcs =
|
|||||||
<*> column HD.char)
|
<*> column HD.char)
|
||||||
<*> (parseVolatility <$> column HD.char)
|
<*> (parseVolatility <$> column HD.char)
|
||||||
|
|
||||||
addName :: ProcDescription -> (Text, ProcDescription)
|
addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)
|
||||||
addName pd = (pdName pd, pd)
|
addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
|
||||||
|
|
||||||
parseArgs :: Text -> [PgArg]
|
parseArgs :: Text -> [PgArg]
|
||||||
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
|
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
|
||||||
@@ -176,31 +177,34 @@ decodeProcs =
|
|||||||
| v == 's' = Stable
|
| v == 's' = Stable
|
||||||
| otherwise = Volatile -- only 'v' can happen here
|
| otherwise = Volatile -- only 'v' can happen here
|
||||||
|
|
||||||
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
allProcs :: H.Statement [Schema] ProcsMap
|
||||||
allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
|
allProcs = H.Statement (toS sql) (arrayParam HE.text) decodeProcs True
|
||||||
|
where
|
||||||
|
sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)"
|
||||||
|
|
||||||
accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
accessibleProcs :: H.Statement Schema ProcsMap
|
||||||
accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
|
accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
|
||||||
where
|
where
|
||||||
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
|
sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"
|
||||||
|
|
||||||
procsSqlQuery :: SqlQuery
|
procsSqlQuery :: SqlQuery
|
||||||
procsSqlQuery = [q|
|
procsSqlQuery = [q|
|
||||||
SELECT p.proname as "proc_name",
|
SELECT
|
||||||
d.description as "proc_description",
|
pn.nspname as "proc_schema",
|
||||||
pg_get_function_arguments(p.oid) as "args",
|
p.proname as "proc_name",
|
||||||
tn.nspname as "rettype_schema",
|
d.description as "proc_description",
|
||||||
coalesce(comp.relname, t.typname) as "rettype_name",
|
pg_get_function_arguments(p.oid) as "args",
|
||||||
p.proretset as "rettype_is_setof",
|
tn.nspname as "rettype_schema",
|
||||||
t.typtype as "rettype_typ",
|
coalesce(comp.relname, t.typname) as "rettype_name",
|
||||||
p.provolatile
|
p.proretset as "rettype_is_setof",
|
||||||
|
t.typtype as "rettype_typ",
|
||||||
|
p.provolatile
|
||||||
FROM pg_proc p
|
FROM pg_proc p
|
||||||
JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
||||||
JOIN pg_type t ON t.oid = p.prorettype
|
JOIN pg_type t ON t.oid = p.prorettype
|
||||||
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
||||||
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
||||||
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
|
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
|
||||||
WHERE pn.nspname = $1
|
|
||||||
|]
|
|]
|
||||||
|
|
||||||
schemaDescription :: H.Statement Schema (Maybe Text)
|
schemaDescription :: H.Statement Schema (Maybe Text)
|
||||||
@@ -384,9 +388,9 @@ allTables =
|
|||||||
GROUP BY table_schema, table_name, insertable
|
GROUP BY table_schema, table_name, insertable
|
||||||
ORDER BY table_schema, table_name |]
|
ORDER BY table_schema, table_name |]
|
||||||
|
|
||||||
allColumns :: [Table] -> H.Statement Schema [Column]
|
allColumns :: [Table] -> H.Statement [Schema] [Column]
|
||||||
allColumns tabs =
|
allColumns tabs =
|
||||||
H.Statement sql (param HE.text) (decodeColumns tabs) True
|
H.Statement sql (arrayParam HE.text) (decodeColumns tabs) True
|
||||||
where
|
where
|
||||||
sql = [q|
|
sql = [q|
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
@@ -424,7 +428,7 @@ allColumns tabs =
|
|||||||
AND c.relkind IN ('r', 'v', 'f', 'm')
|
AND c.relkind IN ('r', 'v', 'f', 'm')
|
||||||
AND r.conrelid = c.oid
|
AND r.conrelid = c.oid
|
||||||
AND c.relnamespace = n.oid
|
AND c.relnamespace = n.oid
|
||||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
|
AND n.nspname <> ANY (ARRAY['pg_catalog', 'information_schema'] || $1)
|
||||||
),
|
),
|
||||||
/*
|
/*
|
||||||
-- CTE based on information_schema.columns
|
-- CTE based on information_schema.columns
|
||||||
@@ -526,7 +530,7 @@ allColumns tabs =
|
|||||||
AND a.attnum > 0
|
AND a.attnum > 0
|
||||||
AND NOT a.attisdropped
|
AND NOT a.attisdropped
|
||||||
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"]))
|
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"]))
|
||||||
AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
|
AND (nc.nspname = ANY ($1) OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
|
||||||
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
|
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -719,9 +723,9 @@ pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
|
|||||||
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
|
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
|
||||||
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
|
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
|
||||||
|
|
||||||
allSourceColumns :: [Column] -> PgVersion -> H.Statement Schema [SourceColumn]
|
allSourceColumns :: [Column] -> PgVersion -> H.Statement [Schema] [SourceColumn]
|
||||||
allSourceColumns cols pgVer =
|
allSourceColumns cols pgVer =
|
||||||
H.Statement sql (param HE.text) (decodeSourceColumns cols) True
|
H.Statement sql (arrayParam HE.text) (decodeSourceColumns cols) True
|
||||||
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
|
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
|
||||||
where
|
where
|
||||||
subselectRegex :: Text
|
subselectRegex :: Text
|
||||||
@@ -740,7 +744,7 @@ allSourceColumns cols pgVer =
|
|||||||
from pg_class c
|
from pg_class c
|
||||||
join pg_namespace n on n.oid = c.relnamespace
|
join pg_namespace n on n.oid = c.relnamespace
|
||||||
join pg_rewrite r on r.ev_class = c.oid
|
join pg_rewrite r on r.ev_class = c.oid
|
||||||
where (c.relkind in ('v', 'm')) and n.nspname = $1
|
where (c.relkind in ('v', 'm')) and n.nspname = ANY ($1)
|
||||||
),
|
),
|
||||||
removed_subselects as(
|
removed_subselects as(
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ data ApiRequestError
|
|||||||
| NoRelBetween Text Text
|
| NoRelBetween Text Text
|
||||||
| AmbiguousRelBetween Text Text [Relation]
|
| AmbiguousRelBetween Text Text [Relation]
|
||||||
| InvalidFilters
|
| InvalidFilters
|
||||||
|
| UnacceptableSchema [Text]
|
||||||
| UnknownRelation -- Unreachable?
|
| UnknownRelation -- Unreachable?
|
||||||
| UnsupportedVerb -- Unreachable?
|
| UnsupportedVerb -- Unreachable?
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
@@ -65,6 +66,7 @@ instance PgrstError ApiRequestError where
|
|||||||
status (ParseRequestError _ _) = HT.status400
|
status (ParseRequestError _ _) = HT.status400
|
||||||
status (NoRelBetween _ _) = HT.status400
|
status (NoRelBetween _ _) = HT.status400
|
||||||
status AmbiguousRelBetween{} = HT.status300
|
status AmbiguousRelBetween{} = HT.status300
|
||||||
|
status (UnacceptableSchema _) = HT.status406
|
||||||
|
|
||||||
headers _ = [toHeader CTApplicationJSON]
|
headers _ = [toHeader CTApplicationJSON]
|
||||||
|
|
||||||
@@ -89,6 +91,8 @@ instance JSON.ToJSON ApiRequestError where
|
|||||||
"message" .= ("Unsupported HTTP verb" :: Text)]
|
"message" .= ("Unsupported HTTP verb" :: Text)]
|
||||||
toJSON InvalidFilters = JSON.object [
|
toJSON InvalidFilters = JSON.object [
|
||||||
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
|
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
|
||||||
|
toJSON (UnacceptableSchema schemas) = JSON.object [
|
||||||
|
"message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas)]
|
||||||
|
|
||||||
compressedRel :: Relation -> JSON.Value
|
compressedRel :: Relation -> JSON.Value
|
||||||
compressedRel rel =
|
compressedRel rel =
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import PostgREST.Config (AppConfig (..), corsPolicy)
|
|||||||
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
|
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
|
||||||
errorResponseFor)
|
errorResponseFor)
|
||||||
import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
|
import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
|
||||||
import Protolude
|
import Protolude hiding (head)
|
||||||
|
|
||||||
runWithClaims :: AppConfig -> JWTAttempt ->
|
runWithClaims :: AppConfig -> JWTAttempt ->
|
||||||
(ApiRequest -> H.Transaction Response) ->
|
(ApiRequest -> H.Transaction Response) ->
|
||||||
@@ -50,7 +50,7 @@ runWithClaims conf eClaims app req =
|
|||||||
appSettingsSql = setLocalQuery mempty <$> configSettings conf
|
appSettingsSql = setLocalQuery mempty <$> configSettings conf
|
||||||
setRoleSql = maybeToList $ (\x ->
|
setRoleSql = maybeToList $ (\x ->
|
||||||
setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
|
setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
|
||||||
setSearchPathSql = setLocalSearchPathQuery $ configSchema conf : configExtraSearchPath conf
|
setSearchPathSql = setLocalSearchPathQuery (iSchema req : configExtraSearchPath conf)
|
||||||
-- role claim defaults to anon if not specified in jwt
|
-- role claim defaults to anon if not specified in jwt
|
||||||
claimsWithRole = M.union claims (M.singleton "role" anon)
|
claimsWithRole = M.union claims (M.singleton "role" anon)
|
||||||
anon = JSON.String . toS $ configAnonRole conf
|
anon = JSON.String . toS $ configAnonRole conf
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ element = HD.element . HD.nonNullable
|
|||||||
|
|
||||||
param :: HE.Value a -> HE.Params a
|
param :: HE.Value a -> HE.Params a
|
||||||
param = HE.param . HE.nonNullable
|
param = HE.param . HE.nonNullable
|
||||||
|
|
||||||
|
arrayParam :: HE.Value a -> HE.Params [a]
|
||||||
|
arrayParam = param . HE.array . HE.dimension foldl' . HE.element . HE.nonNullable
|
||||||
|
|||||||
+20
-11
@@ -2,6 +2,7 @@
|
|||||||
Module : PostgREST.Types
|
Module : PostgREST.Types
|
||||||
Description : PostgREST common types and functions used by the rest of the modules
|
Description : PostgREST common types and functions used by the rest of the modules
|
||||||
-}
|
-}
|
||||||
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
|
|
||||||
module PostgREST.Types where
|
module PostgREST.Types where
|
||||||
@@ -105,8 +106,7 @@ data DbStructure = DbStructure {
|
|||||||
, dbColumns :: [Column]
|
, dbColumns :: [Column]
|
||||||
, dbRelations :: [Relation]
|
, dbRelations :: [Relation]
|
||||||
, dbPrimaryKeys :: [PrimaryKey]
|
, dbPrimaryKeys :: [PrimaryKey]
|
||||||
-- ProcDescription is a list because a function can be overloaded
|
, dbProcs :: ProcsMap
|
||||||
, dbProcs :: M.HashMap Text [ProcDescription]
|
|
||||||
, pgVersion :: PgVersion
|
, pgVersion :: PgVersion
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
@@ -132,7 +132,8 @@ data ProcVolatility = Volatile | Stable | Immutable
|
|||||||
deriving (Eq, Show, Ord)
|
deriving (Eq, Show, Ord)
|
||||||
|
|
||||||
data ProcDescription = ProcDescription {
|
data ProcDescription = ProcDescription {
|
||||||
pdName :: Text
|
pdSchema :: Schema
|
||||||
|
, pdName :: Text
|
||||||
, pdDescription :: Maybe Text
|
, pdDescription :: Maybe Text
|
||||||
, pdArgs :: [PgArg]
|
, pdArgs :: [PgArg]
|
||||||
, pdReturnType :: RetType
|
, pdReturnType :: RetType
|
||||||
@@ -141,18 +142,23 @@ data ProcDescription = ProcDescription {
|
|||||||
|
|
||||||
-- Order by least number of args in the case of overloaded functions
|
-- Order by least number of args in the case of overloaded functions
|
||||||
instance Ord ProcDescription where
|
instance Ord ProcDescription where
|
||||||
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
|
ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2
|
||||||
| name1 == name2 && length args1 < length args2 = LT
|
| schema1 == schema2 && name1 == name2 && length args1 < length args2 = LT
|
||||||
| name1 == name2 && length args1 > length args2 = GT
|
| schema2 == schema2 && name1 == name2 && length args1 > length args2 = GT
|
||||||
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
|
| otherwise = (schema1, name1, des1, args1, rt1, vol1) `compare` (schema2, name2, des2, args2, rt2, vol2)
|
||||||
|
|
||||||
|
-- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).
|
||||||
|
-- | It uses a HashMap for a faster lookup.
|
||||||
|
type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.
|
Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.
|
||||||
An overloaded function can have a different volatility or even a different return type.
|
An overloaded function can have a different volatility or even a different return type.
|
||||||
|
Ideally, handling overloaded functions should be left to pg itself. But we need to know certain proc attributes in advance.
|
||||||
-}
|
-}
|
||||||
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
|
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Maybe ProcDescription
|
||||||
findProc qi payloadKeys paramsAsSingleObject allProcs =
|
findProc qi payloadKeys paramsAsSingleObject allProcs =
|
||||||
case M.lookup (qiName qi) allProcs of
|
case M.lookup qi allProcs of
|
||||||
Nothing -> Nothing
|
Nothing -> Nothing
|
||||||
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription
|
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription
|
||||||
Just procs -> find matches procs -- Handle overloaded functions case
|
Just procs -> find matches procs -- Handle overloaded functions case
|
||||||
@@ -254,8 +260,8 @@ data OrderTerm = OrderTerm {
|
|||||||
data QualifiedIdentifier = QualifiedIdentifier {
|
data QualifiedIdentifier = QualifiedIdentifier {
|
||||||
qiSchema :: Schema
|
qiSchema :: Schema
|
||||||
, qiName :: TableName
|
, qiName :: TableName
|
||||||
} deriving (Show, Eq, Ord)
|
} deriving (Show, Eq, Ord, Generic)
|
||||||
|
instance Hashable QualifiedIdentifier
|
||||||
|
|
||||||
-- | The relationship [cardinality](https://en.wikipedia.org/wiki/Cardinality_(data_modeling)).
|
-- | The relationship [cardinality](https://en.wikipedia.org/wiki/Cardinality_(data_modeling)).
|
||||||
-- | TODO: missing one-to-one
|
-- | TODO: missing one-to-one
|
||||||
@@ -512,6 +518,9 @@ pgVersion112 = PgVersion 110002 "11.2"
|
|||||||
pgVersion114 :: PgVersion
|
pgVersion114 :: PgVersion
|
||||||
pgVersion114 = PgVersion 110004 "11.4"
|
pgVersion114 = PgVersion 110004 "11.4"
|
||||||
|
|
||||||
|
pgVersion121 :: PgVersion
|
||||||
|
pgVersion121 = PgVersion 120001 "12.1"
|
||||||
|
|
||||||
sourceCTEName :: SqlFragment
|
sourceCTEName :: SqlFragment
|
||||||
sourceCTEName = "pg_source"
|
sourceCTEName = "pg_source"
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Test.Hspec
|
|||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
import PostgREST.Types (PgVersion, pgVersion112)
|
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||||
import Protolude hiding (get)
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
@@ -26,7 +26,12 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
|
|||||||
|
|
||||||
it "fails on bad casting (data of the wrong format)" $
|
it "fails on bad casting (data of the wrong format)" $
|
||||||
get "/complex_items?select=settings->foo->>bar::integer"
|
get "/complex_items?select=settings->foo->>bar::integer"
|
||||||
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
`shouldRespondWith` (
|
||||||
|
if actualPgVersion >= pgVersion121 then
|
||||||
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
|
||||||
|
else
|
||||||
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||||
|
)
|
||||||
{ matchStatus = 400 , matchHeaders = [] }
|
{ matchStatus = 400 , matchHeaders = [] }
|
||||||
|
|
||||||
it "obtains a json subfield two levels (string)" $
|
it "obtains a json subfield two levels (string)" $
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
module Feature.MultipleSchemaSpec where
|
||||||
|
|
||||||
|
import Control.Lens ((^?))
|
||||||
|
import Data.Aeson.Lens
|
||||||
|
import Data.Aeson.QQ
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleHeaders), simpleBody)
|
||||||
|
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
|
import PostgREST.Types (PgVersion, pgVersion96)
|
||||||
|
|
||||||
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
|
spec actualPgVersion =
|
||||||
|
describe "multiple schemas in single instance" $ do
|
||||||
|
context "Reading tables on different schemas" $ do
|
||||||
|
it "succeeds in reading table from default schema v1 if no schema is selected via header" $
|
||||||
|
request methodGet "/parents" [] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"name":"parent v1-1"},
|
||||||
|
{"id":2,"name":"parent v1-2"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
|
||||||
|
request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"name":"parent v1-1"},
|
||||||
|
{"id":2,"name":"parent v1-2"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in reading table from schema v2" $
|
||||||
|
request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":3,"name":"parent v2-3"},
|
||||||
|
{"id":4,"name":"parent v2-4"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in reading another_table from schema v2" $
|
||||||
|
request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":5,"another_value":"value 5"},
|
||||||
|
{"id":6,"another_value":"value 6"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "doesn't find another_table in schema v1" $
|
||||||
|
request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
|
||||||
|
|
||||||
|
it "fails trying to read table from unkown schema" $
|
||||||
|
request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||||
|
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||||
|
{
|
||||||
|
matchStatus = 406
|
||||||
|
}
|
||||||
|
|
||||||
|
context "Inserting tables on different schemas" $ do
|
||||||
|
it "succeeds inserting on default schema and returning it" $
|
||||||
|
request methodPost "/childs" [("Prefer", "return=representation")] [json|{"name": "child v1-1", "parent_id": 1}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1, "name": "child v1-1", "parent_id": 1}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 201
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds inserting on the v1 schema and returning its parent" $
|
||||||
|
request methodPost "/childs?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v1")]
|
||||||
|
[json|{"name": "child v1-2", "parent_id": 2}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":2, "parent": {"id": 2, "name": "parent v1-2"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 201
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds inserting on the v2 schema and returning its parent" $
|
||||||
|
request methodPost "/childs?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v2")]
|
||||||
|
[json|{"name": "child v2-3", "parent_id": 3}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1, "parent": {"id": 3, "name": "parent v2-3"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 201
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "fails when inserting on an unknown schema" $
|
||||||
|
request methodPost "/childs" [("Content-Profile", "unknown")]
|
||||||
|
[json|{"name": "child 4", "parent_id": 4}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||||
|
{
|
||||||
|
matchStatus = 406
|
||||||
|
}
|
||||||
|
|
||||||
|
context "calling procs on different schemas" $ do
|
||||||
|
it "succeeds in calling the default schema proc" $
|
||||||
|
request methodGet "/rpc/get_parents_below?id=6" [] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in calling the v1 schema proc and embedding" $
|
||||||
|
request methodGet "/rpc/get_parents_below?id=6&select=id,name,childs(id,name)" [("Accept-Profile", "v1")] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":1,"name":"parent v1-1","childs":[{"id":1,"name":"child v1-1"}]},
|
||||||
|
{"id":2,"name":"parent v1-2","childs":[{"id":2,"name":"child v1-2"}]}] |]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in calling the v2 schema proc and embedding" $
|
||||||
|
request methodGet "/rpc/get_parents_below?id=6&select=id,name,childs(id,name)" [("Accept-Profile", "v2")] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":3,"name":"parent v2-3","childs":[{"id":1,"name":"child v2-3"}]},
|
||||||
|
{"id":4,"name":"parent v2-4","childs":[]}] |]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "Modifying tables on different schemas" $ do
|
||||||
|
it "succeeds in patching on the v1 schema and returning its parent" $
|
||||||
|
request methodPatch "/childs?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "child v1-1 updated"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in patching on the v2 schema and returning its parent" $
|
||||||
|
request methodPatch "/childs?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "child v2-1 updated"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds on deleting on the v2 schema" $ do
|
||||||
|
request methodDelete "/childs?id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")] ""
|
||||||
|
`shouldRespondWith` [json|[{"id": 1, "name": "child v2-1 updated", "parent_id": 3}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
request methodGet "/childs?id=eq.1" [("Accept-Profile", "v2")] ""
|
||||||
|
`shouldRespondWith` "[]"
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
when (actualPgVersion >= pgVersion96) $
|
||||||
|
it "succeeds on PUT on the v2 schema" $
|
||||||
|
request methodPut "/childs?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||||
|
[json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "OpenAPI output" $ do
|
||||||
|
it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
|
||||||
|
req <- request methodGet "/" [] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||||
|
|
||||||
|
let def = simpleBody req ^? key "definitions" . key "parents"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"name" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||||
|
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"name" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "succeeds in reading table definition from schema v2" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||||
|
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"name" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "succeeds in reading another_table definition from schema v2" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||||
|
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"another_value" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "doesn't find another_table definition in schema v1" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||||
|
def `shouldBe` Nothing
|
||||||
|
|
||||||
|
it "fails trying to read definitions from unkown schema" $
|
||||||
|
request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||||
|
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||||
|
{
|
||||||
|
matchStatus = 406
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import Test.Hspec.Wai.JSON
|
|||||||
|
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
|
|
||||||
import PostgREST.Types (PgVersion, pgVersion112)
|
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||||
import Protolude hiding (get)
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
@@ -833,8 +833,12 @@ spec actualPgVersion = do
|
|||||||
|
|
||||||
it "only returns an empty result set if the in value is empty" $
|
it "only returns an empty result set if the in value is empty" $
|
||||||
get "/items_with_different_col_types?int_data=in.( ,3,4)"
|
get "/items_with_different_col_types?int_data=in.( ,3,4)"
|
||||||
`shouldRespondWith`
|
`shouldRespondWith` (
|
||||||
|
if actualPgVersion >= pgVersion121 then
|
||||||
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]
|
||||||
|
else
|
||||||
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
|
||||||
|
)
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
@@ -870,3 +874,9 @@ spec actualPgVersion = do
|
|||||||
{"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
|
{"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
|
||||||
]|]
|
]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do
|
||||||
|
r <- get "/items"
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` noProfileHeader
|
||||||
|
|||||||
+28
-14
@@ -6,16 +6,17 @@ import qualified Hasql.Transaction.Sessions as HT
|
|||||||
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
|
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
|
||||||
updateAction)
|
updateAction)
|
||||||
import Data.Function (id)
|
import Data.Function (id)
|
||||||
|
import Data.List.NonEmpty (toList)
|
||||||
import Data.Time.Clock (getCurrentTime)
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
|
||||||
import Data.IORef
|
import Data.IORef
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
|
|
||||||
import PostgREST.App (postgrest)
|
import PostgREST.App (postgrest)
|
||||||
|
import PostgREST.Config (AppConfig (..))
|
||||||
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
||||||
import PostgREST.Types (DbStructure (..), pgVersion95,
|
import PostgREST.Types (pgVersion95, pgVersion96)
|
||||||
pgVersion96)
|
import Protolude hiding (toList)
|
||||||
import Protolude
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import qualified Feature.AndOrParamsSpec
|
import qualified Feature.AndOrParamsSpec
|
||||||
@@ -31,6 +32,7 @@ import qualified Feature.ExtraSearchPathSpec
|
|||||||
import qualified Feature.HtmlRawOutputSpec
|
import qualified Feature.HtmlRawOutputSpec
|
||||||
import qualified Feature.InsertSpec
|
import qualified Feature.InsertSpec
|
||||||
import qualified Feature.JsonOperatorSpec
|
import qualified Feature.JsonOperatorSpec
|
||||||
|
import qualified Feature.MultipleSchemaSpec
|
||||||
import qualified Feature.NoJwtSpec
|
import qualified Feature.NoJwtSpec
|
||||||
import qualified Feature.NonexistentSchemaSpec
|
import qualified Feature.NonexistentSchemaSpec
|
||||||
import qualified Feature.PgVersion95Spec
|
import qualified Feature.PgVersion95Spec
|
||||||
@@ -50,45 +52,49 @@ import qualified Feature.UpsertSpec
|
|||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
|
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
|
||||||
|
|
||||||
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
|
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
|
||||||
setupDb testDbConn
|
setupDb testDbConn
|
||||||
|
|
||||||
pool <- P.acquire (3, 10, toS testDbConn)
|
pool <- P.acquire (3, 10, toS testDbConn)
|
||||||
|
|
||||||
result <- P.use pool $ do
|
actualPgVersion <- either (panic.show) id <$> P.use pool getPgVersion
|
||||||
ver <- getPgVersion
|
|
||||||
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
|
|
||||||
|
|
||||||
let dbStructure = either (panic.show) id result
|
refDbStructure <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ testCfg testDbConn) actualPgVersion
|
||||||
|
|
||||||
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
|
let
|
||||||
|
-- For tests that run with the same refDbStructure
|
||||||
|
app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())
|
||||||
|
|
||||||
refDbStructure <- newIORef $ Just dbStructure
|
-- For tests that run with a different DbStructure(depends on configSchemas)
|
||||||
|
appDbs cfg = do
|
||||||
let app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())
|
dbs <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ cfg testDbConn) actualPgVersion
|
||||||
|
return ((), postgrest (cfg testDbConn) dbs pool getTime $ pure ())
|
||||||
|
|
||||||
let withApp = app testCfg
|
let withApp = app testCfg
|
||||||
maxRowsApp = app testMaxRowsCfg
|
maxRowsApp = app testMaxRowsCfg
|
||||||
unicodeApp = app testUnicodeCfg
|
|
||||||
proxyApp = app testProxyCfg
|
proxyApp = app testProxyCfg
|
||||||
noJwtApp = app testCfgNoJWT
|
noJwtApp = app testCfgNoJWT
|
||||||
binaryJwtApp = app testCfgBinaryJWT
|
binaryJwtApp = app testCfgBinaryJWT
|
||||||
audJwtApp = app testCfgAudienceJWT
|
audJwtApp = app testCfgAudienceJWT
|
||||||
asymJwkApp = app testCfgAsymJWK
|
asymJwkApp = app testCfgAsymJWK
|
||||||
asymJwkSetApp = app testCfgAsymJWKSet
|
asymJwkSetApp = app testCfgAsymJWKSet
|
||||||
nonexistentSchemaApp = app testNonexistentSchemaCfg
|
|
||||||
extraSearchPathApp = app testCfgExtraSearchPath
|
extraSearchPathApp = app testCfgExtraSearchPath
|
||||||
rootSpecApp = app testCfgRootSpec
|
rootSpecApp = app testCfgRootSpec
|
||||||
htmlRawOutputApp = app testCfgHtmlRawOutput
|
htmlRawOutputApp = app testCfgHtmlRawOutput
|
||||||
responseHeadersApp = app testCfgResponseHeaders
|
responseHeadersApp = app testCfgResponseHeaders
|
||||||
|
|
||||||
|
unicodeApp = appDbs testUnicodeCfg
|
||||||
|
nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
|
||||||
|
multipleSchemaApp = appDbs testMultipleSchemaCfg
|
||||||
|
|
||||||
let reset, analyze :: IO ()
|
let reset, analyze :: IO ()
|
||||||
reset = resetDb testDbConn
|
reset = resetDb testDbConn
|
||||||
analyze = do
|
analyze = do
|
||||||
analyzeTable testDbConn "items"
|
analyzeTable testDbConn "items"
|
||||||
analyzeTable testDbConn "child_entities"
|
analyzeTable testDbConn "child_entities"
|
||||||
|
|
||||||
actualPgVersion = pgVersion dbStructure
|
|
||||||
extraSpecs =
|
extraSpecs =
|
||||||
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
|
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
|
||||||
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]
|
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]
|
||||||
@@ -172,3 +178,11 @@ main = do
|
|||||||
describe "Feature.RootSpec" Feature.RootSpec.spec
|
describe "Feature.RootSpec" Feature.RootSpec.spec
|
||||||
before responseHeadersApp $
|
before responseHeadersApp $
|
||||||
describe "Feature.PgVersion96Spec" Feature.PgVersion96Spec.spec
|
describe "Feature.PgVersion96Spec" Feature.PgVersion96Spec.spec
|
||||||
|
|
||||||
|
-- this test runs with multiple schemas
|
||||||
|
before multipleSchemaApp $
|
||||||
|
describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion
|
||||||
|
|
||||||
|
where
|
||||||
|
setupDbStructure pool schemas ver =
|
||||||
|
either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ getDbStructure (toList schemas) ver)
|
||||||
|
|||||||
+10
-3
@@ -11,6 +11,7 @@ import Control.Monad (void)
|
|||||||
import Data.Aeson (Value (..), decode, encode)
|
import Data.Aeson (Value (..), decode, encode)
|
||||||
import Data.CaseInsensitive (CI (..))
|
import Data.CaseInsensitive (CI (..))
|
||||||
import Data.List (lookup)
|
import Data.List (lookup)
|
||||||
|
import Data.List.NonEmpty (fromList)
|
||||||
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
||||||
import System.Environment (getEnv)
|
import System.Environment (getEnv)
|
||||||
import System.Process (readProcess)
|
import System.Process (readProcess)
|
||||||
@@ -63,7 +64,7 @@ getEnvVarWithDefault var def = toS <$>
|
|||||||
|
|
||||||
_baseCfg :: AppConfig
|
_baseCfg :: AppConfig
|
||||||
_baseCfg = -- Connection Settings
|
_baseCfg = -- Connection Settings
|
||||||
AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
|
AppConfig mempty "postgrest_test_anonymous" Nothing (fromList ["test"]) "localhost" 3000
|
||||||
-- No user configured Unix Socket
|
-- No user configured Unix Socket
|
||||||
Nothing
|
Nothing
|
||||||
-- No user configured Unix Socket file mode (defaults to 660)
|
-- No user configured Unix Socket file mode (defaults to 660)
|
||||||
@@ -93,7 +94,7 @@ testCfgNoJWT :: Text -> AppConfig
|
|||||||
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
|
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
|
||||||
|
|
||||||
testUnicodeCfg :: Text -> AppConfig
|
testUnicodeCfg :: Text -> AppConfig
|
||||||
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" }
|
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["تست"] }
|
||||||
|
|
||||||
testMaxRowsCfg :: Text -> AppConfig
|
testMaxRowsCfg :: Text -> AppConfig
|
||||||
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
|
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
|
||||||
@@ -127,7 +128,7 @@ testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
testNonexistentSchemaCfg :: Text -> AppConfig
|
testNonexistentSchemaCfg :: Text -> AppConfig
|
||||||
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
|
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["nonexistent"] }
|
||||||
|
|
||||||
testCfgExtraSearchPath :: Text -> AppConfig
|
testCfgExtraSearchPath :: Text -> AppConfig
|
||||||
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
|
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
|
||||||
@@ -141,6 +142,9 @@ testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = [
|
|||||||
testCfgResponseHeaders :: Text -> AppConfig
|
testCfgResponseHeaders :: Text -> AppConfig
|
||||||
testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configReqCheck = Just "custom_headers" }
|
testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configReqCheck = Just "custom_headers" }
|
||||||
|
|
||||||
|
testMultipleSchemaCfg :: Text -> AppConfig
|
||||||
|
testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["v1", "v2"] }
|
||||||
|
|
||||||
setupDb :: Text -> IO ()
|
setupDb :: Text -> IO ()
|
||||||
setupDb dbConn = do
|
setupDb dbConn = do
|
||||||
loadFixture dbConn "database"
|
loadFixture dbConn "database"
|
||||||
@@ -181,6 +185,9 @@ matchHeader name valRegex headers =
|
|||||||
noBlankHeader :: [Header] -> Bool
|
noBlankHeader :: [Header] -> Bool
|
||||||
noBlankHeader = notElem mempty
|
noBlankHeader = notElem mempty
|
||||||
|
|
||||||
|
noProfileHeader :: [Header] -> Bool
|
||||||
|
noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers
|
||||||
|
|
||||||
authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
|
authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
|
||||||
authHeaderBasic u p =
|
authHeaderBasic u p =
|
||||||
(hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))
|
(hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))
|
||||||
|
|||||||
Vendored
+9
@@ -572,3 +572,12 @@ INSERT INTO activities(id, schedule_id, camera_id) VALUES(2, 3, 'CAM-123');
|
|||||||
|
|
||||||
TRUNCATE TABLE unit_workdays CASCADE;
|
TRUNCATE TABLE unit_workdays CASCADE;
|
||||||
INSERT INTO unit_workdays VALUES(1, '2019-12-02', 1, 1, 2, 3);
|
INSERT INTO unit_workdays VALUES(1, '2019-12-02', 1, 1, 2, 3);
|
||||||
|
|
||||||
|
TRUNCATE TABLE v1.parents CASCADE;
|
||||||
|
INSERT INTO v1.parents VALUES(1, 'parent v1-1'), (2, 'parent v1-2');
|
||||||
|
|
||||||
|
TRUNCATE TABLE v2.parents CASCADE;
|
||||||
|
INSERT INTO v2.parents VALUES(3, 'parent v2-3'), (4, 'parent v2-4');
|
||||||
|
|
||||||
|
TRUNCATE TABLE v2.another_table CASCADE;
|
||||||
|
INSERT INTO v2.another_table VALUES(5, 'value 5'), (6, 'value 6');
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
set client_min_messages to warning;
|
set client_min_messages to warning;
|
||||||
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions CASCADE;
|
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions, v1, v2 CASCADE;
|
||||||
DROP TYPE IF EXISTS jwt_token CASCADE;
|
DROP TYPE IF EXISTS jwt_token CASCADE;
|
||||||
|
|||||||
Vendored
+9
@@ -6,6 +6,8 @@ GRANT USAGE ON SCHEMA
|
|||||||
, public
|
, public
|
||||||
, "تست"
|
, "تست"
|
||||||
, extensions
|
, extensions
|
||||||
|
, v1
|
||||||
|
, v2
|
||||||
TO postgrest_test_anonymous;
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
-- Schema test objects
|
-- Schema test objects
|
||||||
@@ -122,6 +124,11 @@ GRANT ALL ON TABLE
|
|||||||
, unit_workdays
|
, unit_workdays
|
||||||
, stuff
|
, stuff
|
||||||
, loc_test
|
, loc_test
|
||||||
|
, v1.parents
|
||||||
|
, v2.parents
|
||||||
|
, v2.another_table
|
||||||
|
, v1.childs
|
||||||
|
, v2.childs
|
||||||
TO postgrest_test_anonymous;
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
||||||
@@ -131,6 +138,8 @@ GRANT USAGE ON SEQUENCE
|
|||||||
, items_id_seq
|
, items_id_seq
|
||||||
, callcounter_count
|
, callcounter_count
|
||||||
, leak_id_seq
|
, leak_id_seq
|
||||||
|
, v1.childs_id_seq
|
||||||
|
, v2.childs_id_seq
|
||||||
TO postgrest_test_anonymous;
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
-- Privileges for non anonymous users
|
-- Privileges for non anonymous users
|
||||||
|
|||||||
Vendored
+44
@@ -18,6 +18,8 @@ CREATE SCHEMA private;
|
|||||||
CREATE SCHEMA test;
|
CREATE SCHEMA test;
|
||||||
CREATE SCHEMA تست;
|
CREATE SCHEMA تست;
|
||||||
CREATE SCHEMA extensions;
|
CREATE SCHEMA extensions;
|
||||||
|
CREATE SCHEMA v1;
|
||||||
|
CREATE SCHEMA v2;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
|
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
|
||||||
@@ -1693,3 +1695,45 @@ create table loc_test (
|
|||||||
id int primary key
|
id int primary key
|
||||||
, c text
|
, c text
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- tables to test multi schema access in one instance
|
||||||
|
create table v1.parents (
|
||||||
|
id int primary key
|
||||||
|
, name text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table v1.childs (
|
||||||
|
id serial primary key
|
||||||
|
, name text
|
||||||
|
, parent_id int
|
||||||
|
, constraint parent foreign key(parent_id)
|
||||||
|
references v1.parents(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create function v1.get_parents_below(id int)
|
||||||
|
returns setof v1.parents as $$
|
||||||
|
select * from v1.parents where id < $1;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create table v2.parents (
|
||||||
|
id int primary key
|
||||||
|
, name text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table v2.childs (
|
||||||
|
id serial primary key
|
||||||
|
, name text
|
||||||
|
, parent_id int
|
||||||
|
, constraint parent foreign key(parent_id)
|
||||||
|
references v2.parents(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table v2.another_table (
|
||||||
|
id int primary key
|
||||||
|
, another_value text
|
||||||
|
);
|
||||||
|
|
||||||
|
create function v2.get_parents_below(id int)
|
||||||
|
returns setof v2.parents as $$
|
||||||
|
select * from v2.parents where id < $1;
|
||||||
|
$$ language sql;
|
||||||
|
|||||||
+12
-12
@@ -94,21 +94,21 @@ setUp
|
|||||||
|
|
||||||
echo "Running memory usage tests.."
|
echo "Running memory usage tests.."
|
||||||
|
|
||||||
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M"
|
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "13M"
|
||||||
jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M"
|
jsonKeyTest "1M" "POST" "/leak?columns=blob" "13M"
|
||||||
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M"
|
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "13M"
|
||||||
|
|
||||||
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M"
|
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "41M"
|
||||||
jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M"
|
jsonKeyTest "10M" "POST" "/leak?columns=blob" "41M"
|
||||||
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M"
|
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "41M"
|
||||||
|
|
||||||
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M"
|
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "171M"
|
||||||
jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M"
|
jsonKeyTest "50M" "POST" "/leak?columns=blob" "171M"
|
||||||
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M"
|
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "171M"
|
||||||
|
|
||||||
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M"
|
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "11M"
|
||||||
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M"
|
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "11M"
|
||||||
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M"
|
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "21M"
|
||||||
|
|
||||||
cleanUp
|
cleanUp
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user