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
|
||||
- #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
|
||||
- #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
|
||||
|
||||
|
||||
+10
-8
@@ -45,7 +45,7 @@ import PostgREST.OpenAPI (isMalformedProxyUri)
|
||||
import PostgREST.Types (ConnectionStatus (..), DbStructure,
|
||||
PgVersion (..), Schema,
|
||||
minimumPgVersion)
|
||||
import Protolude hiding (hPutStrLn, replace)
|
||||
import Protolude hiding (hPutStrLn, head, replace)
|
||||
|
||||
|
||||
#ifndef mingw32_HOST_OS
|
||||
@@ -73,11 +73,11 @@ import System.Posix.Signals
|
||||
connectionWorker
|
||||
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
||||
-> 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 Bool -- ^ Used as a binary Semaphore
|
||||
-> IO ()
|
||||
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
||||
connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do
|
||||
isWorkerOn <- readIORef refIsWorkerOn
|
||||
unless isWorkerOn $ do
|
||||
atomicWriteIORef refIsWorkerOn True
|
||||
@@ -93,7 +93,7 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
||||
NotConnected -> return () -- Unreachable
|
||||
Connected actualPgVersion -> do -- Procede with initialization
|
||||
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
|
||||
case result of
|
||||
Left e -> do
|
||||
@@ -162,7 +162,8 @@ main = do
|
||||
-- readOptions builds the 'AppConfig' from the config file specified on the
|
||||
-- command line
|
||||
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
|
||||
let host = configHost conf
|
||||
let schemas = toList $ configSchemas conf
|
||||
host = configHost conf
|
||||
port = configPort conf
|
||||
proxy = configOpenAPIProxyUri conf
|
||||
maybeSocketAddr = configSocket conf
|
||||
@@ -175,6 +176,7 @@ main = do
|
||||
. setServerName (toS $ "postgrest/" <> prettyVersion) $
|
||||
defaultSettings
|
||||
|
||||
|
||||
whenLeft socketFileMode panic
|
||||
|
||||
-- Checks that the provided proxy uri is formated correctly
|
||||
@@ -205,7 +207,7 @@ main = do
|
||||
connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
schemas
|
||||
refDbStructure
|
||||
refIsWorkerOn
|
||||
--
|
||||
@@ -227,7 +229,7 @@ main = do
|
||||
Catch $ connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
schemas
|
||||
refDbStructure
|
||||
refIsWorkerOn
|
||||
) Nothing
|
||||
@@ -246,7 +248,7 @@ main = do
|
||||
(connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
schemas
|
||||
refDbStructure
|
||||
refIsWorkerOn)
|
||||
in case maybeSocketAddr of
|
||||
|
||||
@@ -148,6 +148,7 @@ test-suite spec
|
||||
Feature.UpsertSpec
|
||||
Feature.RawOutputTypesSpec
|
||||
Feature.HtmlRawOutputSpec
|
||||
Feature.MultipleSchemaSpec
|
||||
SpecHelper
|
||||
TestTypes
|
||||
hs-source-dirs: test
|
||||
|
||||
@@ -28,7 +28,8 @@ import qualified Data.Vector as V
|
||||
|
||||
import Control.Arrow ((***))
|
||||
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.Ranged.Ranges (Range (..), emptyRange,
|
||||
rangeIntersection)
|
||||
@@ -48,7 +49,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
|
||||
rangeLimit, rangeOffset, rangeRequested,
|
||||
restrictRange)
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
import Protolude hiding (head)
|
||||
|
||||
type RequestBody = BL.ByteString
|
||||
|
||||
@@ -96,11 +97,14 @@ data ApiRequest = ApiRequest {
|
||||
, iCookies :: [(Text, Text)] -- ^ Request Cookies
|
||||
, iPath :: ByteString -- ^ Raw request path
|
||||
, 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.
|
||||
userApiRequest :: Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||
userApiRequest schema rootSpec req reqBody
|
||||
userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||
userApiRequest confSchemas rootSpec req reqBody
|
||||
| isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas
|
||||
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
|
||||
| topLevelRange == emptyRange = Left InvalidRange
|
||||
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
|
||||
@@ -137,6 +141,8 @@ userApiRequest schema rootSpec req reqBody
|
||||
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
|
||||
, iPath = rawPathInfo req
|
||||
, iMethod = method
|
||||
, iProfile = profile
|
||||
, iSchema = schema
|
||||
}
|
||||
where
|
||||
-- queryString with '+' converted to ' '(space)
|
||||
@@ -208,6 +214,18 @@ userApiRequest schema rootSpec req reqBody
|
||||
"DELETE" -> ActionDelete
|
||||
"OPTIONS" -> ActionInfo
|
||||
_ -> 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
|
||||
[] -> case rootSpec of
|
||||
Just pName -> TargetProc (QualifiedIdentifier schema pName) True
|
||||
|
||||
+29
-21
@@ -78,8 +78,9 @@ postgrest conf refDbStructure pool getTime worker =
|
||||
Nothing -> respond . errorResponseFor $ ConnectionLostError
|
||||
Just dbStructure -> do
|
||||
response <- do
|
||||
-- Need to parse ?columns early because findProc needs it to solve overloaded functions
|
||||
let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body
|
||||
-- Need to parse ?columns early because findProc needs it to solve overloaded functions.
|
||||
-- TODO: move this logic to the app function
|
||||
let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body
|
||||
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
|
||||
case apiReqCols of
|
||||
Left err -> return . errorResponseFor $ err
|
||||
@@ -145,8 +146,9 @@ app dbStructure proc cols conf apiRequest =
|
||||
else pure tableTotal
|
||||
| otherwise -> pure tableTotal
|
||||
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
|
||||
headers = addHeadersIfNotIncluded
|
||||
[toHeader contentType, contentRange, contentLocationH tName (iCanonicalQS apiRequest)]
|
||||
headers = addHeadersIfNotIncluded (catMaybes [
|
||||
Just $ toHeader contentType, Just contentRange,
|
||||
Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])
|
||||
(unwrapGucHeader <$> ghdrs)
|
||||
rBody = if headersOnly then mempty else toS body
|
||||
return $
|
||||
@@ -168,19 +170,18 @@ app dbStructure proc cols conf apiRequest =
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right ghdrs -> do
|
||||
let
|
||||
(ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then (Just $ toHeader contentType, toS body)
|
||||
else (Nothing, mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes [
|
||||
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then ([Just $ toHeader contentType, profileH], toS body)
|
||||
else ([], mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes ([
|
||||
if null fields
|
||||
then Nothing
|
||||
else Just $ locationH tName fields
|
||||
, ctHeader
|
||||
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||
, if null pkCols && isNothing (iOnConflict apiRequest)
|
||||
then Nothing
|
||||
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
|
||||
]) (unwrapGucHeader <$> ghdrs)
|
||||
] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
@@ -206,10 +207,10 @@ app dbStructure proc cols conf apiRequest =
|
||||
| iPreferRepresentation apiRequest == Full = status200
|
||||
| otherwise = status204
|
||||
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
|
||||
(ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then (Just $ toHeader contentType, toS body)
|
||||
else (Nothing, mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes [Just contentRangeHeader, ctHeader]) (unwrapGucHeader <$> ghdrs)
|
||||
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then ([Just $ toHeader contentType, profileH], toS body)
|
||||
else ([], mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
@@ -239,7 +240,7 @@ app dbStructure proc cols conf apiRequest =
|
||||
case gucHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
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)
|
||||
-- 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
|
||||
@@ -267,10 +268,10 @@ app dbStructure proc cols conf apiRequest =
|
||||
let
|
||||
status = if iPreferRepresentation apiRequest == Full then status200 else status204
|
||||
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||
(ctHeader, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then (Just $ toHeader contentType, toS body)
|
||||
else (Nothing, mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes [Just contentRangeHeader, ctHeader]) (unwrapGucHeader <$> ghdrs)
|
||||
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||
then ([Just $ toHeader contentType, profileH], toS body)
|
||||
else ([], mempty)
|
||||
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||
if contentType == CTSingularJSON
|
||||
&& queryTotal /= 1
|
||||
then do
|
||||
@@ -305,7 +306,9 @@ app dbStructure proc cols conf apiRequest =
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right ghdrs -> do
|
||||
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
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then do
|
||||
@@ -329,7 +332,7 @@ app dbStructure proc cols conf apiRequest =
|
||||
H.statement tSchema accessibleTables <*>
|
||||
H.statement tSchema schemaDescription <*>
|
||||
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
|
||||
|
||||
@@ -343,6 +346,7 @@ app dbStructure proc cols conf apiRequest =
|
||||
topLevelRange = iTopLevelRange apiRequest
|
||||
returnsScalar = maybe False procReturnsScalar proc
|
||||
pgVer = pgVersion dbStructure
|
||||
profileH = contentProfileH <$> iProfile apiRequest
|
||||
|
||||
readSqlParts s t =
|
||||
let
|
||||
@@ -413,3 +417,7 @@ locationH tName fields =
|
||||
contentLocationH :: TableName -> ByteString -> Header
|
||||
contentLocationH tName 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 Crypto.JWT (StringOrURI, stringOrUri)
|
||||
import Data.List (lookup)
|
||||
import Data.List.NonEmpty (NonEmpty, fromList)
|
||||
import Data.Scientific (floatingOrInteger)
|
||||
import Data.Text (dropEnd, dropWhileEnd,
|
||||
intercalate, lines, splitOn,
|
||||
@@ -71,7 +72,7 @@ data AppConfig = AppConfig {
|
||||
configDatabase :: Text
|
||||
, configAnonRole :: Text
|
||||
, configOpenAPIProxyUri :: Maybe Text
|
||||
, configSchema :: Text
|
||||
, configSchemas :: NonEmpty Text
|
||||
, configHost :: Text
|
||||
, configPort :: Int
|
||||
, configSocket :: Maybe Text
|
||||
@@ -154,8 +155,8 @@ readOptions = do
|
||||
AppConfig
|
||||
<$> reqString "db-uri"
|
||||
<*> reqString "db-anon-role"
|
||||
<*> optString "openapi-server-proxy-uri"
|
||||
<*> reqString "db-schema"
|
||||
<*> optString "server-proxy-uri"
|
||||
<*> (fromList . splitOnCommas <$> reqValue "db-schema")
|
||||
<*> (fromMaybe "!4" <$> optString "server-host")
|
||||
<*> (fromMaybe 3000 <$> optInt "server-port")
|
||||
<*> optString "server-unix-socket"
|
||||
@@ -199,6 +200,9 @@ readOptions = do
|
||||
reqString :: C.Key -> C.Parser C.Config Text
|
||||
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 k = mfilter (/= "") <$> C.optional k C.string
|
||||
|
||||
|
||||
@@ -44,15 +44,15 @@ import PostgREST.Private.Common
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
|
||||
getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
|
||||
getDbStructure schema pgVer = do
|
||||
HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
|
||||
getDbStructure :: [Schema] -> PgVersion -> HT.Transaction DbStructure
|
||||
getDbStructure schemas pgVer = do
|
||||
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
|
||||
cols <- HT.statement schema $ allColumns tabs
|
||||
srcCols <- HT.statement schema $ allSourceColumns cols pgVer
|
||||
cols <- HT.statement schemas $ allColumns tabs
|
||||
srcCols <- HT.statement schemas $ allSourceColumns cols pgVer
|
||||
m2oRels <- HT.statement () $ allM2ORels tabs cols
|
||||
keys <- HT.statement () $ allPrimaryKeys tabs
|
||||
procs <- HT.statement schema allProcs
|
||||
procs <- HT.statement schemas allProcs
|
||||
|
||||
let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels
|
||||
cols' = addForeignKeys rels cols
|
||||
@@ -126,13 +126,14 @@ sourceColumnFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
|
||||
col2 = findCol s2 t2 c2
|
||||
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 =
|
||||
-- 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
|
||||
tblRow = ProcDescription
|
||||
procRow = ProcDescription
|
||||
<$> column HD.text
|
||||
<*> column HD.text
|
||||
<*> nullableColumn HD.text
|
||||
<*> (parseArgs <$> column HD.text)
|
||||
<*> (parseRetType
|
||||
@@ -142,8 +143,8 @@ decodeProcs =
|
||||
<*> column HD.char)
|
||||
<*> (parseVolatility <$> column HD.char)
|
||||
|
||||
addName :: ProcDescription -> (Text, ProcDescription)
|
||||
addName pd = (pdName pd, pd)
|
||||
addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)
|
||||
addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
|
||||
|
||||
parseArgs :: Text -> [PgArg]
|
||||
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
|
||||
@@ -176,31 +177,34 @@ decodeProcs =
|
||||
| v == 's' = Stable
|
||||
| otherwise = Volatile -- only 'v' can happen here
|
||||
|
||||
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
||||
allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
|
||||
allProcs :: H.Statement [Schema] ProcsMap
|
||||
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
|
||||
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 = [q|
|
||||
SELECT p.proname as "proc_name",
|
||||
d.description as "proc_description",
|
||||
pg_get_function_arguments(p.oid) as "args",
|
||||
tn.nspname as "rettype_schema",
|
||||
coalesce(comp.relname, t.typname) as "rettype_name",
|
||||
p.proretset as "rettype_is_setof",
|
||||
t.typtype as "rettype_typ",
|
||||
p.provolatile
|
||||
SELECT
|
||||
pn.nspname as "proc_schema",
|
||||
p.proname as "proc_name",
|
||||
d.description as "proc_description",
|
||||
pg_get_function_arguments(p.oid) as "args",
|
||||
tn.nspname as "rettype_schema",
|
||||
coalesce(comp.relname, t.typname) as "rettype_name",
|
||||
p.proretset as "rettype_is_setof",
|
||||
t.typtype as "rettype_typ",
|
||||
p.provolatile
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
||||
JOIN pg_type t ON t.oid = p.prorettype
|
||||
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
||||
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
||||
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
|
||||
WHERE pn.nspname = $1
|
||||
|]
|
||||
|
||||
schemaDescription :: H.Statement Schema (Maybe Text)
|
||||
@@ -384,9 +388,9 @@ allTables =
|
||||
GROUP BY table_schema, table_name, insertable
|
||||
ORDER BY table_schema, table_name |]
|
||||
|
||||
allColumns :: [Table] -> H.Statement Schema [Column]
|
||||
allColumns :: [Table] -> H.Statement [Schema] [Column]
|
||||
allColumns tabs =
|
||||
H.Statement sql (param HE.text) (decodeColumns tabs) True
|
||||
H.Statement sql (arrayParam HE.text) (decodeColumns tabs) True
|
||||
where
|
||||
sql = [q|
|
||||
SELECT DISTINCT
|
||||
@@ -424,7 +428,7 @@ allColumns tabs =
|
||||
AND c.relkind IN ('r', 'v', 'f', 'm')
|
||||
AND r.conrelid = c.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
|
||||
@@ -526,7 +530,7 @@ allColumns tabs =
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
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))*/
|
||||
)
|
||||
SELECT
|
||||
@@ -719,9 +723,9 @@ pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
|
||||
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
|
||||
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 =
|
||||
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
|
||||
where
|
||||
subselectRegex :: Text
|
||||
@@ -740,7 +744,7 @@ allSourceColumns cols pgVer =
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
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(
|
||||
select
|
||||
|
||||
@@ -51,6 +51,7 @@ data ApiRequestError
|
||||
| NoRelBetween Text Text
|
||||
| AmbiguousRelBetween Text Text [Relation]
|
||||
| InvalidFilters
|
||||
| UnacceptableSchema [Text]
|
||||
| UnknownRelation -- Unreachable?
|
||||
| UnsupportedVerb -- Unreachable?
|
||||
deriving (Show, Eq)
|
||||
@@ -65,6 +66,7 @@ instance PgrstError ApiRequestError where
|
||||
status (ParseRequestError _ _) = HT.status400
|
||||
status (NoRelBetween _ _) = HT.status400
|
||||
status AmbiguousRelBetween{} = HT.status300
|
||||
status (UnacceptableSchema _) = HT.status406
|
||||
|
||||
headers _ = [toHeader CTApplicationJSON]
|
||||
|
||||
@@ -89,6 +91,8 @@ instance JSON.ToJSON ApiRequestError where
|
||||
"message" .= ("Unsupported HTTP verb" :: Text)]
|
||||
toJSON InvalidFilters = JSON.object [
|
||||
"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 rel =
|
||||
|
||||
@@ -27,7 +27,7 @@ import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
|
||||
errorResponseFor)
|
||||
import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
|
||||
import Protolude
|
||||
import Protolude hiding (head)
|
||||
|
||||
runWithClaims :: AppConfig -> JWTAttempt ->
|
||||
(ApiRequest -> H.Transaction Response) ->
|
||||
@@ -50,7 +50,7 @@ runWithClaims conf eClaims app req =
|
||||
appSettingsSql = setLocalQuery mempty <$> configSettings conf
|
||||
setRoleSql = maybeToList $ (\x ->
|
||||
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
|
||||
claimsWithRole = M.union claims (M.singleton "role" anon)
|
||||
anon = JSON.String . toS $ configAnonRole conf
|
||||
|
||||
@@ -20,3 +20,6 @@ element = HD.element . HD.nonNullable
|
||||
|
||||
param :: HE.Value a -> HE.Params a
|
||||
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
|
||||
Description : PostgREST common types and functions used by the rest of the modules
|
||||
-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
|
||||
module PostgREST.Types where
|
||||
@@ -105,8 +106,7 @@ data DbStructure = DbStructure {
|
||||
, dbColumns :: [Column]
|
||||
, dbRelations :: [Relation]
|
||||
, dbPrimaryKeys :: [PrimaryKey]
|
||||
-- ProcDescription is a list because a function can be overloaded
|
||||
, dbProcs :: M.HashMap Text [ProcDescription]
|
||||
, dbProcs :: ProcsMap
|
||||
, pgVersion :: PgVersion
|
||||
} deriving (Show, Eq)
|
||||
|
||||
@@ -132,7 +132,8 @@ data ProcVolatility = Volatile | Stable | Immutable
|
||||
deriving (Eq, Show, Ord)
|
||||
|
||||
data ProcDescription = ProcDescription {
|
||||
pdName :: Text
|
||||
pdSchema :: Schema
|
||||
, pdName :: Text
|
||||
, pdDescription :: Maybe Text
|
||||
, pdArgs :: [PgArg]
|
||||
, pdReturnType :: RetType
|
||||
@@ -141,18 +142,23 @@ data ProcDescription = ProcDescription {
|
||||
|
||||
-- Order by least number of args in the case of overloaded functions
|
||||
instance Ord ProcDescription where
|
||||
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
|
||||
| name1 == name2 && length args1 < length args2 = LT
|
||||
| name1 == name2 && length args1 > length args2 = GT
|
||||
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
|
||||
ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2
|
||||
| schema1 == schema2 && name1 == name2 && length args1 < length args2 = LT
|
||||
| schema2 == schema2 && name1 == name2 && length args1 > length args2 = GT
|
||||
| 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.
|
||||
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 =
|
||||
case M.lookup (qiName qi) allProcs of
|
||||
case M.lookup qi allProcs of
|
||||
Nothing -> Nothing
|
||||
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
|
||||
@@ -254,8 +260,8 @@ data OrderTerm = OrderTerm {
|
||||
data QualifiedIdentifier = QualifiedIdentifier {
|
||||
qiSchema :: Schema
|
||||
, 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)).
|
||||
-- | TODO: missing one-to-one
|
||||
@@ -512,6 +518,9 @@ pgVersion112 = PgVersion 110002 "11.2"
|
||||
pgVersion114 :: PgVersion
|
||||
pgVersion114 = PgVersion 110004 "11.4"
|
||||
|
||||
pgVersion121 :: PgVersion
|
||||
pgVersion121 = PgVersion 120001 "12.1"
|
||||
|
||||
sourceCTEName :: SqlFragment
|
||||
sourceCTEName = "pg_source"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
@@ -26,7 +26,12 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
|
||||
it "fails on bad casting (data of the wrong format)" $
|
||||
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 = [] }
|
||||
|
||||
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 PostgREST.Types (PgVersion, pgVersion112)
|
||||
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
@@ -833,8 +833,12 @@ spec actualPgVersion = do
|
||||
|
||||
it "only returns an empty result set if the in value is empty" $
|
||||
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: \"\""} |]
|
||||
)
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
@@ -870,3 +874,9 @@ spec actualPgVersion = do
|
||||
{"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
|
||||
]|]
|
||||
{ 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,
|
||||
updateAction)
|
||||
import Data.Function (id)
|
||||
import Data.List.NonEmpty (toList)
|
||||
import Data.Time.Clock (getCurrentTime)
|
||||
|
||||
import Data.IORef
|
||||
import Test.Hspec
|
||||
|
||||
import PostgREST.App (postgrest)
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
||||
import PostgREST.Types (DbStructure (..), pgVersion95,
|
||||
pgVersion96)
|
||||
import Protolude
|
||||
import PostgREST.Types (pgVersion95, pgVersion96)
|
||||
import Protolude hiding (toList)
|
||||
import SpecHelper
|
||||
|
||||
import qualified Feature.AndOrParamsSpec
|
||||
@@ -31,6 +32,7 @@ import qualified Feature.ExtraSearchPathSpec
|
||||
import qualified Feature.HtmlRawOutputSpec
|
||||
import qualified Feature.InsertSpec
|
||||
import qualified Feature.JsonOperatorSpec
|
||||
import qualified Feature.MultipleSchemaSpec
|
||||
import qualified Feature.NoJwtSpec
|
||||
import qualified Feature.NonexistentSchemaSpec
|
||||
import qualified Feature.PgVersion95Spec
|
||||
@@ -50,45 +52,49 @@ import qualified Feature.UpsertSpec
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
|
||||
|
||||
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
|
||||
setupDb testDbConn
|
||||
|
||||
pool <- P.acquire (3, 10, toS testDbConn)
|
||||
|
||||
result <- P.use pool $ do
|
||||
ver <- getPgVersion
|
||||
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
|
||||
actualPgVersion <- either (panic.show) id <$> P.use pool getPgVersion
|
||||
|
||||
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
|
||||
|
||||
let app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())
|
||||
-- For tests that run with a different DbStructure(depends on configSchemas)
|
||||
appDbs cfg = do
|
||||
dbs <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ cfg testDbConn) actualPgVersion
|
||||
return ((), postgrest (cfg testDbConn) dbs pool getTime $ pure ())
|
||||
|
||||
let withApp = app testCfg
|
||||
maxRowsApp = app testMaxRowsCfg
|
||||
unicodeApp = app testUnicodeCfg
|
||||
proxyApp = app testProxyCfg
|
||||
noJwtApp = app testCfgNoJWT
|
||||
binaryJwtApp = app testCfgBinaryJWT
|
||||
audJwtApp = app testCfgAudienceJWT
|
||||
asymJwkApp = app testCfgAsymJWK
|
||||
asymJwkSetApp = app testCfgAsymJWKSet
|
||||
nonexistentSchemaApp = app testNonexistentSchemaCfg
|
||||
extraSearchPathApp = app testCfgExtraSearchPath
|
||||
rootSpecApp = app testCfgRootSpec
|
||||
htmlRawOutputApp = app testCfgHtmlRawOutput
|
||||
responseHeadersApp = app testCfgResponseHeaders
|
||||
|
||||
unicodeApp = appDbs testUnicodeCfg
|
||||
nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
|
||||
multipleSchemaApp = appDbs testMultipleSchemaCfg
|
||||
|
||||
let reset, analyze :: IO ()
|
||||
reset = resetDb testDbConn
|
||||
analyze = do
|
||||
analyzeTable testDbConn "items"
|
||||
analyzeTable testDbConn "child_entities"
|
||||
|
||||
actualPgVersion = pgVersion dbStructure
|
||||
extraSpecs =
|
||||
[("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
|
||||
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]
|
||||
@@ -172,3 +178,11 @@ main = do
|
||||
describe "Feature.RootSpec" Feature.RootSpec.spec
|
||||
before responseHeadersApp $
|
||||
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.CaseInsensitive (CI (..))
|
||||
import Data.List (lookup)
|
||||
import Data.List.NonEmpty (fromList)
|
||||
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
||||
import System.Environment (getEnv)
|
||||
import System.Process (readProcess)
|
||||
@@ -63,7 +64,7 @@ getEnvVarWithDefault var def = toS <$>
|
||||
|
||||
_baseCfg :: AppConfig
|
||||
_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
|
||||
Nothing
|
||||
-- No user configured Unix Socket file mode (defaults to 660)
|
||||
@@ -93,7 +94,7 @@ testCfgNoJWT :: Text -> AppConfig
|
||||
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
|
||||
|
||||
testUnicodeCfg :: Text -> AppConfig
|
||||
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" }
|
||||
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["تست"] }
|
||||
|
||||
testMaxRowsCfg :: Text -> AppConfig
|
||||
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
|
||||
@@ -127,7 +128,7 @@ testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
|
||||
}
|
||||
|
||||
testNonexistentSchemaCfg :: Text -> AppConfig
|
||||
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
|
||||
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["nonexistent"] }
|
||||
|
||||
testCfgExtraSearchPath :: Text -> AppConfig
|
||||
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
|
||||
@@ -141,6 +142,9 @@ testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = [
|
||||
testCfgResponseHeaders :: Text -> AppConfig
|
||||
testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configReqCheck = Just "custom_headers" }
|
||||
|
||||
testMultipleSchemaCfg :: Text -> AppConfig
|
||||
testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["v1", "v2"] }
|
||||
|
||||
setupDb :: Text -> IO ()
|
||||
setupDb dbConn = do
|
||||
loadFixture dbConn "database"
|
||||
@@ -181,6 +185,9 @@ matchHeader name valRegex headers =
|
||||
noBlankHeader :: [Header] -> Bool
|
||||
noBlankHeader = notElem mempty
|
||||
|
||||
noProfileHeader :: [Header] -> Bool
|
||||
noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers
|
||||
|
||||
authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
|
||||
authHeaderBasic 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;
|
||||
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;
|
||||
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;
|
||||
|
||||
Vendored
+9
@@ -6,6 +6,8 @@ GRANT USAGE ON SCHEMA
|
||||
, public
|
||||
, "تست"
|
||||
, extensions
|
||||
, v1
|
||||
, v2
|
||||
TO postgrest_test_anonymous;
|
||||
|
||||
-- Schema test objects
|
||||
@@ -122,6 +124,11 @@ GRANT ALL ON TABLE
|
||||
, unit_workdays
|
||||
, stuff
|
||||
, loc_test
|
||||
, v1.parents
|
||||
, v2.parents
|
||||
, v2.another_table
|
||||
, v1.childs
|
||||
, v2.childs
|
||||
TO postgrest_test_anonymous;
|
||||
|
||||
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
||||
@@ -131,6 +138,8 @@ GRANT USAGE ON SEQUENCE
|
||||
, items_id_seq
|
||||
, callcounter_count
|
||||
, leak_id_seq
|
||||
, v1.childs_id_seq
|
||||
, v2.childs_id_seq
|
||||
TO postgrest_test_anonymous;
|
||||
|
||||
-- Privileges for non anonymous users
|
||||
|
||||
Vendored
+44
@@ -18,6 +18,8 @@ CREATE SCHEMA private;
|
||||
CREATE SCHEMA test;
|
||||
CREATE SCHEMA تست;
|
||||
CREATE SCHEMA extensions;
|
||||
CREATE SCHEMA v1;
|
||||
CREATE SCHEMA v2;
|
||||
|
||||
--
|
||||
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
|
||||
@@ -1693,3 +1695,45 @@ create table loc_test (
|
||||
id int primary key
|
||||
, 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.."
|
||||
|
||||
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M"
|
||||
jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M"
|
||||
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M"
|
||||
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "13M"
|
||||
jsonKeyTest "1M" "POST" "/leak?columns=blob" "13M"
|
||||
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "13M"
|
||||
|
||||
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M"
|
||||
jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M"
|
||||
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M"
|
||||
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "41M"
|
||||
jsonKeyTest "10M" "POST" "/leak?columns=blob" "41M"
|
||||
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "41M"
|
||||
|
||||
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M"
|
||||
jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M"
|
||||
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M"
|
||||
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "171M"
|
||||
jsonKeyTest "50M" "POST" "/leak?columns=blob" "171M"
|
||||
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "171M"
|
||||
|
||||
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M"
|
||||
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M"
|
||||
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M"
|
||||
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "11M"
|
||||
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "11M"
|
||||
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "21M"
|
||||
|
||||
cleanUp
|
||||
|
||||
|
||||
Reference in New Issue
Block a user