From 14f41bedc95c9a2fcdf0a0a4dab89600f1a937e4 Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Wed, 8 Jul 2026 20:58:28 -0500 Subject: [PATCH] add: configs as gucs for db-root-spec Fixes #3029 --- src/PostgREST/ApiRequest.hs | 18 ++++++++------ src/PostgREST/ApiRequest/Payload.hs | 4 +-- src/PostgREST/ApiRequest/Types.hs | 4 ++- src/PostgREST/Plan/Negotiate.hs | 2 +- src/PostgREST/Query/PreQuery.hs | 15 +++++++++++- test/spec/Feature/OpenApi/RootSpec.hs | 35 ++++++++++++++++++++++++--- test/spec/fixtures/schema.sql | 27 ++++++++++----------- 7 files changed, 75 insertions(+), 30 deletions(-) diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index b3434887d1..23b1145e09 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -62,6 +62,7 @@ import Protolude -} data ApiRequest = ApiRequest { iAction :: Action -- ^ Action on the resource + , iResource :: Resource -- ^ Resource resolved from the request path , iRange :: HM.HashMap Text NonnegRange -- ^ Requested range of rows within response , iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level , iPayload :: Maybe Payload -- ^ Data sent by client and used for mutation actions @@ -99,6 +100,7 @@ userApiRequest conf prefs req reqBody = do , iCookies = iCkies , iPath = rawPathInfo req , iMethod = method + , iResource = resource , iSchema = schema , iNegotiatedByProfile = negotiatedByProfile , iAcceptMediaType = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept" @@ -111,7 +113,7 @@ userApiRequest conf prefs req reqBody = do iHdrs = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie] iCkies = maybe [] parseCookies $ lookupHeader "Cookie" contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type" - actIsInvokeSafe x = case x of {ActDb (ActRoutine _ (InvRead _)) -> True; _ -> False} + actIsInvokeSafe x = case x of {ActDb (ActRoutine _ (InvRead _)) -> True; _ -> False} -- | Parses the Prefer header userPreferences :: AppConfig -> Request -> TimezoneNames -> Preferences.Preferences @@ -126,21 +128,21 @@ getResource AppConfig{configOpenApiMode, configDbRootSpec} = \case [] -> case (configOpenApiMode,configDbRootSpec) of (OADisabled,_) -> Left OpenAPIDisabled - (_, Just qi) -> Right $ ResourceRoutine (qiName qi) + (_, Just qi) -> Right $ ResourceRoutine (qiName qi) True (_, Nothing) -> Right ResourceSchema [table] -> Right $ ResourceRelation table - ["rpc", pName] -> Right $ ResourceRoutine pName + ["rpc", pName] -> Right $ ResourceRoutine pName False _ -> Left InvalidResourcePath getAction :: Resource -> Schema -> ByteString -> Either ApiRequestError Action getAction resource schema method = case (resource, method) of - (ResourceRoutine rout, "HEAD") -> Right . ActDb $ ActRoutine (qi rout) $ InvRead True - (ResourceRoutine rout, "GET") -> Right . ActDb $ ActRoutine (qi rout) $ InvRead False - (ResourceRoutine rout, "POST") -> Right . ActDb $ ActRoutine (qi rout) Inv - (ResourceRoutine rout, "OPTIONS") -> Right $ ActRoutineInfo (qi rout) $ InvRead True - (ResourceRoutine _, _) -> Left $ InvalidRpcMethod method + (ResourceRoutine rout _, "HEAD") -> Right . ActDb $ ActRoutine (qi rout) $ InvRead True + (ResourceRoutine rout _, "GET") -> Right . ActDb $ ActRoutine (qi rout) $ InvRead False + (ResourceRoutine rout _, "POST") -> Right . ActDb $ ActRoutine (qi rout) Inv + (ResourceRoutine rout _, "OPTIONS") -> Right $ ActRoutineInfo (qi rout) $ InvRead True + (ResourceRoutine _ _, _) -> Left $ InvalidRpcMethod method (ResourceRelation rel, "HEAD") -> Right . ActDb $ ActRelationRead (qi rel) True (ResourceRelation rel, "GET") -> Right . ActDb $ ActRelationRead (qi rel) False diff --git a/src/PostgREST/ApiRequest/Payload.hs b/src/PostgREST/ApiRequest/Payload.hs index 2c959f895e..f65e3f363a 100644 --- a/src/PostgREST/ApiRequest/Payload.hs +++ b/src/PostgREST/ApiRequest/Payload.hs @@ -74,13 +74,13 @@ getPayload reqBody contentMediaType QueryParams{qsColumns} action = do shouldParsePayload = case action of ActDb (ActRelationMut _ MutationDelete) -> False ActDb (ActRelationMut _ _) -> True - ActDb (ActRoutine _ Inv) -> True + ActDb (ActRoutine _ Inv) -> True _ -> False columns = case action of ActDb (ActRelationMut _ MutationCreate) -> qsColumns ActDb (ActRelationMut _ MutationUpdate) -> qsColumns - ActDb (ActRoutine _ Inv) -> qsColumns + ActDb (ActRoutine _ Inv) -> qsColumns _ -> Nothing isProc = case action of diff --git a/src/PostgREST/ApiRequest/Types.hs b/src/PostgREST/ApiRequest/Types.hs index bf3ddd9b03..a145de1771 100644 --- a/src/PostgREST/ApiRequest/Types.hs +++ b/src/PostgREST/ApiRequest/Types.hs @@ -58,9 +58,11 @@ data Mutation | MutationUpdate deriving Eq +type IsRoot = Bool + data Resource = ResourceRelation Text - | ResourceRoutine Text + | ResourceRoutine Text IsRoot | ResourceSchema data DbAction diff --git a/src/PostgREST/Plan/Negotiate.hs b/src/PostgREST/Plan/Negotiate.hs index 3224b4c745..3904ea5ecf 100644 --- a/src/PostgREST/Plan/Negotiate.hs +++ b/src/PostgREST/Plan/Negotiate.hs @@ -58,7 +58,7 @@ negotiateContent conf ApiRequest{iAction=act, iPreferences=Preferences{preferRep -- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849 -- TODO: despite no aggregate, these are responding with a Content-Type, which is not correct. (ActDb (ActRelationRead _ True), Just (_, mt)) -> Right (NoAgg, mt) - (ActDb (ActRoutine _ (InvRead True)), Just (_, mt)) -> Right (NoAgg, mt) + (ActDb (ActRoutine _ (InvRead True)), Just (_, mt)) -> Right (NoAgg, mt) (_, Just (x, mt)) -> Right (x, mt) where firstAcceptedPick = listToMaybe $ mapMaybe matchMT accepts -- If there are multiple accepted media types, pick the first. This is usual in content negotiation. diff --git a/src/PostgREST/Query/PreQuery.hs b/src/PostgREST/Query/PreQuery.hs index bb931ffb22..78ed81f17d 100644 --- a/src/PostgREST/Query/PreQuery.hs +++ b/src/PostgREST/Query/PreQuery.hs @@ -13,6 +13,7 @@ import qualified Data.Aeson as JSON import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.HashMap.Strict as HM +import qualified Data.Text as T import qualified Hasql.DynamicStatements.Snippet as SQL hiding (sql) @@ -20,6 +21,7 @@ import qualified Hasql.DynamicStatements.Snippet as SQL hiding (sql) import PostgREST.ApiRequest (ApiRequest (..)) import PostgREST.ApiRequest.Preferences (PreferTimezone (..), Preferences (..)) +import PostgREST.ApiRequest.Types (Resource (..)) import PostgREST.Auth.Types (AuthResult (..)) import PostgREST.Config (AppConfig (..)) import PostgREST.Plan (CrudPlan (..), @@ -31,6 +33,7 @@ import PostgREST.Query.SqlFragment (escapeIdentList, fromQi, setConfigWithDynamicName) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Routine (Routine (..)) +import PostgREST.Version (prettyVersion) import Protolude hiding (Handler) @@ -40,7 +43,7 @@ txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} = -- To ensure `GRANT SET ON PARAMETER TO authenticator` works, the role settings must be set before the impersonated role. -- Otherwise the GRANT SET would have to be applied to the impersonated role. See https://github.com/PostgREST/postgrest/issues/3045 "select " <> intercalateSnippet ", " ( - searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ funcSettingsSql ++ appSettingsSql + searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ funcSettingsSql ++ appSettingsSql ++ rootSpecSettingsSql ) where methodSql = setConfigWithConstantName ("request.method", iMethod) @@ -54,6 +57,15 @@ txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} = roleSql = [setConfigWithConstantName ("role", authRole)] roleSettingsSql = setConfigWithDynamicName <$> HM.toList (fromMaybe mempty $ HM.lookup authRole configRoleSettings) appSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> configAppSettings + rootSpecSettingsSql + | isRootSpec iResource = + [ setConfigWithConstantName ("pgrst.server_host", toUtf8 configServerHost) + , setConfigWithConstantName ("pgrst.server_port", toUtf8 (show configServerPort :: Text)) + , setConfigWithConstantName ("pgrst.openapi_server_proxy_uri", maybe mempty toUtf8 configOpenApiServerProxyUri) + , setConfigWithConstantName ("pgrst.db_schemas", toUtf8 $ T.intercalate "," $ toList configDbSchemas) + , setConfigWithConstantName ("pgrst.version", prettyVersion) + ] + | otherwise = mempty timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences funcSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> funcSettings searchPathSql = @@ -62,6 +74,7 @@ txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} = funcSettings = case dbActPlan of DbCrud _ CallReadPlan{crProc} -> pdFuncSettings crProc _ -> mempty + isRootSpec resource = case resource of { ResourceRoutine _ True -> True; _ -> False } -- runs the pre-request function preReqQuery :: QualifiedIdentifier -> SQL.Snippet diff --git a/test/spec/Feature/OpenApi/RootSpec.hs b/test/spec/Feature/OpenApi/RootSpec.hs index 5391ebffc9..046bba1657 100644 --- a/test/spec/Feature/OpenApi/RootSpec.hs +++ b/test/spec/Feature/OpenApi/RootSpec.hs @@ -8,19 +8,29 @@ import Test.Hspec.Wai.JSON import PostgREST.Config (AppConfig (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) +import PostgREST.Version (prettyVersion) import Protolude hiding (get) import SpecHelper spec :: SpecWithConfig -spec withConfig = withConfig (baseCfg { configDbRootSpec = Just $ QualifiedIdentifier mempty "root" }) $ +spec withConfig = withConfig (baseCfg + { configDbRootSpec = Just $ QualifiedIdentifier mempty "root" + , configDbSchemas = "test" :| ["v1"] + , configOpenApiServerProxyUri = Just "https://example.com/base" + }) $ describe "root spec function" $ do it "accepts application/openapi+json" $ do request methodGet "/" [("Accept","application/openapi+json")] "" `shouldRespondWith` [json|{ "swagger": "2.0", - "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"} + "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}, + "pgrst_server_host": "localhost", + "pgrst_server_port": "3000", + "pgrst_openapi_server_proxy_uri": "https://example.com/base", + "pgrst_db_schemas": "test,v1", + "pgrst_version": #{decodeUtf8 prettyVersion} }|] { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] } @@ -29,6 +39,25 @@ spec withConfig = withConfig (baseCfg { configDbRootSpec = Just $ QualifiedIdent [("Accept","application/json")] "" `shouldRespondWith` [json|{ "swagger": "2.0", - "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"} + "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}, + "pgrst_server_host": "localhost", + "pgrst_server_port": "3000", + "pgrst_openapi_server_proxy_uri": "https://example.com/base", + "pgrst_db_schemas": "test,v1", + "pgrst_version": #{decodeUtf8 prettyVersion} }|] { matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } + + it "returns null root spec GUCs on /rpc/root" $ do + request methodGet "/rpc/root" + [("Accept","application/openapi+json")] "" `shouldRespondWith` + [json|{ + "swagger": "2.0", + "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}, + "pgrst_server_host": null, + "pgrst_server_port": null, + "pgrst_openapi_server_proxy_uri": null, + "pgrst_db_schemas": null, + "pgrst_version": null + }|] + { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] } diff --git a/test/spec/fixtures/schema.sql b/test/spec/fixtures/schema.sql index cce6136e86..9f43f2f9d4 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -1865,20 +1865,19 @@ returns integer as $$ $$ language sql; create or replace function root() returns "application/openapi+json" as $_$ -declare -openapi json = $$ - { - "swagger": "2.0", - "info":{ - "title":"PostgREST API", - "description":"This is a dynamic API generated by PostgREST" - } - } -$$; -begin - return openapi; -end -$_$ language plpgsql; + select json_build_object( + 'swagger', '2.0', + 'info', json_build_object( + 'title', 'PostgREST API', + 'description', 'This is a dynamic API generated by PostgREST' + ), + 'pgrst_server_host', nullif(current_setting('pgrst.server_host', true), ''), + 'pgrst_server_port', nullif(current_setting('pgrst.server_port', true), ''), + 'pgrst_openapi_server_proxy_uri', nullif(current_setting('pgrst.openapi_server_proxy_uri', true), ''), + 'pgrst_db_schemas', nullif(current_setting('pgrst.db_schemas', true), ''), + 'pgrst_version', nullif(current_setting('pgrst.version', true), '') + )::json; +$_$ language sql; create or replace function welcome() returns "text/plain" as $$ select 'Welcome to PostgREST'::"text/plain";