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:
Steve Chavez
2020-03-30 14:04:20 -05:00
committed by GitHub
co-authored by Mahmoud Kassem Mahmoud Kassem
parent a80eb2ff0e
commit 691bb5640d
21 changed files with 590 additions and 114 deletions
+1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+22 -4
View File
@@ -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
View File
@@ -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)
+7 -3
View File
@@ -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
+35 -31
View File
@@ -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
+4
View File
@@ -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 =
+2 -2
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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)" $
+324
View File
@@ -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
}
+12 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+9
View File
@@ -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');
+1 -1
View File
@@ -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;
+9
View File
@@ -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
+44
View File
@@ -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
View File
@@ -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