diff --git a/CHANGELOG.md b/CHANGELOG.md index f6bc56e64..5ebb4a0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/main/Main.hs b/main/Main.hs index 1d03fe421..f6030ab75 100644 --- a/main/Main.hs +++ b/main/Main.hs @@ -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 diff --git a/postgrest.cabal b/postgrest.cabal index 3dac3c109..24b6bfebf 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -148,6 +148,7 @@ test-suite spec Feature.UpsertSpec Feature.RawOutputTypesSpec Feature.HtmlRawOutputSpec + Feature.MultipleSchemaSpec SpecHelper TestTypes hs-source-dirs: test diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index a5ed551cd..cf095bb38 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -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 diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 4c90ff9a0..14b69ebf6 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -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) diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 0326a64a6..77302a5b5 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -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 diff --git a/src/PostgREST/DbStructure.hs b/src/PostgREST/DbStructure.hs index 53d2f6c6e..f3cadf1a4 100644 --- a/src/PostgREST/DbStructure.hs +++ b/src/PostgREST/DbStructure.hs @@ -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 diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index 05b74439f..1e0b8e42d 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -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 = diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs index 89dc46493..93d3e4ec2 100644 --- a/src/PostgREST/Middleware.hs +++ b/src/PostgREST/Middleware.hs @@ -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 diff --git a/src/PostgREST/Private/Common.hs b/src/PostgREST/Private/Common.hs index fafabdc04..c1acc9258 100644 --- a/src/PostgREST/Private/Common.hs +++ b/src/PostgREST/Private/Common.hs @@ -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 diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 2e65e3a7e..0d5c34b06 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -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" diff --git a/test/Feature/JsonOperatorSpec.hs b/test/Feature/JsonOperatorSpec.hs index 7367bcdab..b9318d4bd 100644 --- a/test/Feature/JsonOperatorSpec.hs +++ b/test/Feature/JsonOperatorSpec.hs @@ -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)" $ diff --git a/test/Feature/MultipleSchemaSpec.hs b/test/Feature/MultipleSchemaSpec.hs new file mode 100644 index 000000000..d45bc888e --- /dev/null +++ b/test/Feature/MultipleSchemaSpec.hs @@ -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.", + "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.", + "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.", + "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.", + "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 + } diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index fec9b74b6..05574e75d 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -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 diff --git a/test/Main.hs b/test/Main.hs index 84cf717ea..869f6a667 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -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) diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 40f158da7..394a9f4c3 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -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)) diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql index c37da7519..5eff88712 100644 --- a/test/fixtures/data.sql +++ b/test/fixtures/data.sql @@ -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'); diff --git a/test/fixtures/database.sql b/test/fixtures/database.sql index 52e6aad94..80b684e2c 100644 --- a/test/fixtures/database.sql +++ b/test/fixtures/database.sql @@ -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; diff --git a/test/fixtures/privileges.sql b/test/fixtures/privileges.sql index 70230cd99..34be7600b 100644 --- a/test/fixtures/privileges.sql +++ b/test/fixtures/privileges.sql @@ -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 diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 5f868f241..0fb270403 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -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; diff --git a/test/memory-tests.sh b/test/memory-tests.sh index 2da192e7a..78377c99c 100755 --- a/test/memory-tests.sh +++ b/test/memory-tests.sh @@ -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