add: configs as json GUC for db-root-spec

These are needed for a complete OpenAPI spec
This commit is contained in:
steve-chavez
2026-08-03 23:41:03 -05:00
committed by Steve Chavez
parent f84c44dafa
commit f34ca15e84
6 changed files with 94 additions and 32 deletions
+1
View File
@@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. From versio
- Add config `server-reuseport` to allow starting multiple PostgREST instances using the same port on supported platforms by @mkleczek in #4703, #4694 - Add config `server-reuseport` to allow starting multiple PostgREST instances using the same port on supported platforms by @mkleczek in #4703, #4694
- Make config `log-level` reloadable by @taimoorzaeem in #5113 - Make config `log-level` reloadable by @taimoorzaeem in #5113
- Optimize schema cache domain type resolution by using `pg_basetype` on PostgreSQL 17+ by @joelonsql in #4567 - Optimize schema cache domain type resolution by using `pg_basetype` on PostgreSQL 17+ by @joelonsql in #4567
- When `db-root-spec` is used, a `request.root.configs` GUC will be available to build a complete custom OpenAPI response by @steve-chavez in #3029
### Fixed ### Fixed
+22 -11
View File
@@ -66,24 +66,34 @@ You can override the whole default response with a function result. To do this,
db-root-spec = "root" db-root-spec = "root"
When this function is called, the ``request.root.configs`` GUC contains a JSON object with the following keys:
* ``server_host`` (string)
* ``server_port`` (string)
* ``openapi_server_proxy_uri`` (string)
* ``db_schemas`` (JSON array of strings)
* ``version`` (string)
The root spec function can read this JSON and use its values when constructing the response:
.. code:: postgres .. code:: postgres
create or replace function root() returns json as $_$ create or replace function root() returns json as $_$
declare declare
openapi json = $$ configs json := current_setting('request.root.configs', true)::json;
{
"swagger": "2.0",
"info":{
"title":"Overridden",
"description":"This is a my own API"
}
}
$$;
begin begin
return openapi; return json_build_object(
'swagger', '2.0',
'info', json_build_object(
'title', 'Overridden',
'description', 'This is my own API',
'version', configs->>'version'
)
);
end end
$_$ language plpgsql; $_$ language plpgsql;
.. code-block:: bash .. code-block:: bash
curl http://localhost:3000 curl http://localhost:3000
@@ -96,6 +106,7 @@ You can override the whole default response with a function result. To do this,
"swagger": "2.0", "swagger": "2.0",
"info":{ "info":{
"title":"Overridden", "title":"Overridden",
"description":"This is a my own API" "description":"This is my own API",
"version":"14.0.0"
} }
} }
+8 -6
View File
@@ -57,6 +57,7 @@ import Protolude
-} -}
data ApiRequest = ApiRequest { data ApiRequest = ApiRequest {
iAction :: Action -- ^ Action on the resource iAction :: Action -- ^ Action on the resource
, iIsRootRoutine :: Bool -- ^ If the request wants the root routine
, iRange :: HM.HashMap Text NonnegRange -- ^ Requested range of rows within response , iRange :: HM.HashMap Text NonnegRange -- ^ Requested range of rows within response
, iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level , iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level
, iPayload :: Maybe Payload -- ^ Data sent by client and used for mutation actions , iPayload :: Maybe Payload -- ^ Data sent by client and used for mutation actions
@@ -76,7 +77,7 @@ data ApiRequest = ApiRequest {
-- | Examines HTTP request and translates it into user intent. -- | Examines HTTP request and translates it into user intent.
userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> RequestBody -> Either ApiRequestError ApiRequest userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest conf prefs req reqBody = do userApiRequest conf prefs req reqBody = do
resource <- getResource conf $ pathInfo req (resource, isRootRoutine) <- getResource conf $ pathInfo req
(schema, negotiatedByProfile) <- getSchema conf hdrs method (schema, negotiatedByProfile) <- getSchema conf hdrs method
act <- getAction resource schema method act <- getAction resource schema method
qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req
@@ -94,6 +95,7 @@ userApiRequest conf prefs req reqBody = do
, iCookies = iCkies , iCookies = iCkies
, iPath = rawPathInfo req , iPath = rawPathInfo req
, iMethod = method , iMethod = method
, iIsRootRoutine = isRootRoutine
, iSchema = schema , iSchema = schema
, iNegotiatedByProfile = negotiatedByProfile , iNegotiatedByProfile = negotiatedByProfile
, iAcceptMediaType = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept" , iAcceptMediaType = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
@@ -116,16 +118,16 @@ userPreferences conf req timezones = Preferences.fromHeaders (configDbTxAllowOve
userBearerAuth :: Request -> Maybe ByteString userBearerAuth :: Request -> Maybe ByteString
userBearerAuth req = extractBearerAuth =<< lookup hAuthorization (requestHeaders req) userBearerAuth req = extractBearerAuth =<< lookup hAuthorization (requestHeaders req)
getResource :: AppConfig -> [Text] -> Either ApiRequestError Resource getResource :: AppConfig -> [Text] -> Either ApiRequestError (Resource, Bool)
getResource AppConfig{configOpenApiMode, configDbRootSpec} = \case getResource AppConfig{configOpenApiMode, configDbRootSpec} = \case
[] -> [] ->
case (configOpenApiMode,configDbRootSpec) of case (configOpenApiMode,configDbRootSpec) of
(OADisabled,_) -> Left OpenAPIDisabled (OADisabled,_) -> Left OpenAPIDisabled
(_, Just qi) -> Right $ ResourceRoutine (qiName qi) (_, Just qi) -> Right (ResourceRoutine (qiName qi), True)
(_, Nothing) -> Right ResourceSchema (_, Nothing) -> Right (ResourceSchema, False)
[table] -> Right $ ResourceRelation table [table] -> Right (ResourceRelation table, False)
["rpc", pName] -> Right $ ResourceRoutine pName ["rpc", pName] -> Right (ResourceRoutine pName , False)
_ -> Left InvalidResourcePath _ -> Left InvalidResourcePath
getAction :: Resource -> Schema -> ByteString -> Either ApiRequestError Action getAction :: Resource -> Schema -> ByteString -> Either ApiRequestError Action
+16 -1
View File
@@ -29,6 +29,7 @@ import PostgREST.Query.SqlFragment (escapeIdentList, fromQi,
setConfigWithDynamicName) setConfigWithDynamicName)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Routine (Routine (..)) import PostgREST.SchemaCache.Routine (Routine (..))
import PostgREST.Version (prettyVersion)
import Protolude hiding (Handler) import Protolude hiding (Handler)
@@ -38,7 +39,7 @@ txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} =
-- To ensure `GRANT SET ON PARAMETER <superuser_setting> TO authenticator` works, the role settings must be set before the impersonated role. -- To ensure `GRANT SET ON PARAMETER <superuser_setting> 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 -- Otherwise the GRANT SET would have to be applied to the impersonated role. See https://github.com/PostgREST/postgrest/issues/3045
"select " <> intercalateSnippet ", " ( "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 where
methodSql = setConfigWithConstantName ("request.method", iMethod) methodSql = setConfigWithConstantName ("request.method", iMethod)
@@ -52,6 +53,20 @@ txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} =
roleSql = [setConfigWithConstantName ("role", authRole)] roleSql = [setConfigWithConstantName ("role", authRole)]
roleSettingsSql = setConfigWithDynamicName <$> HM.toList (fromMaybe mempty $ HM.lookup authRole configRoleSettings) roleSettingsSql = setConfigWithDynamicName <$> HM.toList (fromMaybe mempty $ HM.lookup authRole configRoleSettings)
appSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> configAppSettings appSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> configAppSettings
rootSpecSettingsSql
| iIsRootRoutine =
[ setConfigWithConstantName
( "request.root.configs"
, LBS.toStrict $ JSON.encode $ JSON.object
[ "server_host" JSON..= configServerHost
, "server_port" JSON..= (show configServerPort :: Text)
, "openapi_server_proxy_uri" JSON..= configOpenApiServerProxyUri
, "db_schemas" JSON..= toList configDbSchemas
, "version" JSON..= decodeUtf8 prettyVersion
]
)
]
| otherwise = mempty
timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences
funcSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> funcSettings funcSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> funcSettings
searchPathSql = searchPathSql =
+32 -3
View File
@@ -8,19 +8,29 @@ import Test.Hspec.Wai.JSON
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.Version (prettyVersion)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
spec :: SpecWithConfig 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 describe "root spec function" $ do
it "accepts application/openapi+json" $ do it "accepts application/openapi+json" $ do
request methodGet "/" request methodGet "/"
[("Accept","application/openapi+json")] "" `shouldRespondWith` [("Accept","application/openapi+json")] "" `shouldRespondWith`
[json|{ [json|{
"swagger": "2.0", "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"] } { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }
@@ -29,6 +39,25 @@ spec withConfig = withConfig (baseCfg { configDbRootSpec = Just $ QualifiedIdent
[("Accept","application/json")] "" `shouldRespondWith` [("Accept","application/json")] "" `shouldRespondWith`
[json|{ [json|{
"swagger": "2.0", "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"] } { matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] }
it "returns null GUCs on non-root paths" $ 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"] }
+15 -11
View File
@@ -1866,18 +1866,22 @@ $$ language sql;
create or replace function root() returns "application/openapi+json" as $_$ create or replace function root() returns "application/openapi+json" as $_$
declare declare
openapi json = $$ pgrst_config json := nullif(current_setting('request.root.configs', true), '')::json;
{
"swagger": "2.0",
"info":{
"title":"PostgREST API",
"description":"This is a dynamic API generated by PostgREST"
}
}
$$;
begin begin
return openapi; return (
end 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', pgrst_config->>'server_host',
'pgrst_server_port', pgrst_config->>'server_port',
'pgrst_openapi_server_proxy_uri', pgrst_config->>'openapi_server_proxy_uri',
'pgrst_db_schemas', pgrst_config->'db_schemas',
'pgrst_version', pgrst_config->>'version'
)::json);
end;
$_$ language plpgsql; $_$ language plpgsql;
create or replace function welcome() returns "text/plain" as $$ create or replace function welcome() returns "text/plain" as $$