diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd52a53c..b75cf7148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 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 +- 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 diff --git a/docs/references/api/openapi.rst b/docs/references/api/openapi.rst index ec546eb72..9e3bc8fc2 100644 --- a/docs/references/api/openapi.rst +++ b/docs/references/api/openapi.rst @@ -66,24 +66,34 @@ You can override the whole default response with a function result. To do this, 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 create or replace function root() returns json as $_$ declare - openapi json = $$ - { - "swagger": "2.0", - "info":{ - "title":"Overridden", - "description":"This is a my own API" - } - } - $$; + configs json := current_setting('request.root.configs', true)::json; 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 $_$ language plpgsql; + .. code-block:: bash 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", "info":{ "title":"Overridden", - "description":"This is a my own API" + "description":"This is my own API", + "version":"14.0.0" } } diff --git a/src/library/PostgREST/ApiRequest.hs b/src/library/PostgREST/ApiRequest.hs index eb09e6e6e..3ad0b20a9 100644 --- a/src/library/PostgREST/ApiRequest.hs +++ b/src/library/PostgREST/ApiRequest.hs @@ -57,6 +57,7 @@ import Protolude -} data ApiRequest = ApiRequest { 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 , iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level , 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. userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> RequestBody -> Either ApiRequestError ApiRequest userApiRequest conf prefs req reqBody = do - resource <- getResource conf $ pathInfo req + (resource, isRootRoutine) <- getResource conf $ pathInfo req (schema, negotiatedByProfile) <- getSchema conf hdrs method act <- getAction resource schema method qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req @@ -94,6 +95,7 @@ userApiRequest conf prefs req reqBody = do , iCookies = iCkies , iPath = rawPathInfo req , iMethod = method + , iIsRootRoutine = isRootRoutine , iSchema = schema , iNegotiatedByProfile = negotiatedByProfile , 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 req = extractBearerAuth =<< lookup hAuthorization (requestHeaders req) -getResource :: AppConfig -> [Text] -> Either ApiRequestError Resource +getResource :: AppConfig -> [Text] -> Either ApiRequestError (Resource, Bool) getResource AppConfig{configOpenApiMode, configDbRootSpec} = \case [] -> case (configOpenApiMode,configDbRootSpec) of (OADisabled,_) -> Left OpenAPIDisabled - (_, Just qi) -> Right $ ResourceRoutine (qiName qi) - (_, Nothing) -> Right ResourceSchema + (_, Just qi) -> Right (ResourceRoutine (qiName qi), True) + (_, Nothing) -> Right (ResourceSchema, False) - [table] -> Right $ ResourceRelation table - ["rpc", pName] -> Right $ ResourceRoutine pName + [table] -> Right (ResourceRelation table, False) + ["rpc", pName] -> Right (ResourceRoutine pName , False) _ -> Left InvalidResourcePath getAction :: Resource -> Schema -> ByteString -> Either ApiRequestError Action diff --git a/src/library/PostgREST/Query/PreQuery.hs b/src/library/PostgREST/Query/PreQuery.hs index 59cd4cc79..6c581c524 100644 --- a/src/library/PostgREST/Query/PreQuery.hs +++ b/src/library/PostgREST/Query/PreQuery.hs @@ -29,6 +29,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) @@ -38,7 +39,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) @@ -52,6 +53,20 @@ 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 + | 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 funcSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> funcSettings searchPathSql = diff --git a/test/spec/Feature/OpenApi/RootSpec.hs b/test/spec/Feature/OpenApi/RootSpec.hs index 5391ebffc..6ebc4b05c 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 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"] } diff --git a/test/spec/fixtures/schema.sql b/test/spec/fixtures/schema.sql index cce6136e8..776d41082 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -1866,18 +1866,22 @@ $$ 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" - } - } -$$; + pgrst_config json := nullif(current_setting('request.root.configs', true), '')::json; begin - return openapi; -end + return ( + 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; create or replace function welcome() returns "text/plain" as $$