Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. From versio
- Add config `db-timezone-enabled` for optional querying of timezones by @taimoorzaeem in #4751
- Log schema cache queries timings on `log-level=debug` by @steve-chavez in #4805
- Add GHC runtime metrics to the metrics endpoint by @mkleczek in #4862
- Add opt-in `openapi-metadata` config that emits an `x-postgrest-typegen-metadata` vendor extension in the OpenAPI output, carrying catalog-grade metadata (object kind, identity/generated columns, full relationships with cardinality, function return types, composite types, computed fields) for faithful client type generation

### Fixed

Expand Down
4 changes: 4 additions & 0 deletions docs/postgrest.dict
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,7 @@ wfts
www
debouncing
deduplicates
cardinality
lossy
Typegen
updatability
13 changes: 13 additions & 0 deletions docs/references/api/openapi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ Similarly, you can override the API title by commenting the schema.

If you need to include the ``security`` and ``securityDefinitions`` options, set the :ref:`openapi-security-active` configuration to ``true``.

Typegen metadata extension
--------------------------

PostgREST's OpenAPI is a name-based description of the API surface, which is lossy for client type generation (it cannot distinguish tables from views, identity columns, function return types, composite types, etc.). To support faithful type generation directly from the OpenAPI document, set ``openapi-metadata`` to ``true``. PostgREST then adds a top-level ``x-postgrest-typegen-metadata`` `vendor extension <https://spec.openapis.org/oas/v2.0#specification-extensions>`_ to the output, carrying catalog-grade metadata not otherwise expressible in OpenAPI:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laurenceisla Since you've done a lot of work on OpenAPI, is x-postgrest-typegen-metadata our only option for this new metadata?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation is on slack (private link)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can see, there are some overlapping fields between x-postgrest-typegen-metadata and regular OpenAPI/JSONSchema like nullable, enum, default, etc. for tables and views (and some others for functions). So, an alternative would be to add these x-<my-custom-field> to each Object (parameter, path, etc.), similar to what's proposed here. However the relationships field has more info that may be difficult to express in the OpenAPI output, I can't find a way to easily convey this rn.

I guess having all the relevant info inside the x-postgrest-typegen-metadata is an easier way to retrieve the data that is needed. However, I think we should still check if there's a way to avoid doing this and using the default OpenAPI output instead to avoid duplicity. But, if there's no alternative then yes, the custom field would be needed.

@laurenceisla laurenceisla Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation is on slack (private link)

From a comment there:

I'm not sure if OpenAPI is the right approach since it might not support all usecases needed by the typegen package. I think it's easier if each typegen target has its own SQL code you run against the db and it builds the output from that.

I wonder if having https://github.com/PostgREST/postgrest-openapi would make the creation of custom fields like this one easier (since it's using SQL directly). The customers that need to modify the output wouldn't need to wait for a PR to be approved or to check if PostgREST would allow it in core in the first place.


- object kind (``table`` / ``view`` / ``materialized_view`` / ``foreign_table``) and view updatability
- per-column ``is_identity`` / ``identity_generation`` / ``is_generated`` and pg_catalog type names
- the full relationship graph with constraint names, cardinality (one-to-one detection) and view-expanded relationships
- function argument and return types (including set-returning detection and the returned relation), plus computed fields and computed relationships
- standalone composite types and enum types

The extension is ``false`` by default, leaving the standard OpenAPI output unchanged. Standard OpenAPI consumers ignore unknown ``x-`` keys. This extension is consumed by `@supabase/postgrest-typegen <https://github.com/supabase/pg-toolbelt>`_'s ``openApiToGeneratorMetadata`` producer.

You can use a tool like `Swagger UI <https://swagger.io/tools/swagger-ui/>`_ to create beautiful documentation from the description and to host an interactive web-based dashboard. The dashboard allows developers to make requests against a live PostgREST server, and provides guidance with request headers and example request bodies.

.. important::
Expand Down
15 changes: 15 additions & 0 deletions docs/references/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,21 @@ log-query

Logs the SQL query for the corresponding request at the current :ref:`log-level`. See :ref:`sql_query_logs`.

.. _openapi-metadata:

openapi-metadata
----------------

=============== =================================
**Type** Boolean
**Default** False
**Reloadable** Y
**Environment** PGRST_OPENAPI_METADATA
**In-Database** pgrst.openapi_metadata
=============== =================================

When enabled, adds a top-level ``x-postgrest-typegen-metadata`` vendor extension to the :ref:`OpenAPI output <open-api>` carrying catalog-grade metadata (object kind, identity/generated columns, full relationships with cardinality, function return types, composite types and computed fields) for faithful client type generation. Disabled by default; standard OpenAPI consumers ignore the extra ``x-`` key.

.. _openapi-mode:

openapi-mode
Expand Down
3 changes: 3 additions & 0 deletions src/PostgREST/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ data AppConfig = AppConfig
, configOpenApiMode :: OpenAPIMode
, configOpenApiSecurityActive :: Bool
, configOpenApiServerProxyUri :: Maybe Text
, configOpenApiMetadata :: Bool
, configServerCorsAllowedOrigins :: Maybe [Text]
, configServerHost :: Text
, configServerPort :: Int
Expand Down Expand Up @@ -198,6 +199,7 @@ toText conf =
,("openapi-mode", q . dumpOpenApiMode . configOpenApiMode)
,("openapi-security-active", T.toLower . show . configOpenApiSecurityActive)
,("openapi-server-proxy-uri", q . fromMaybe mempty . configOpenApiServerProxyUri)
,("openapi-metadata", T.toLower . show . configOpenApiMetadata)
,("server-cors-allowed-origins", q . maybe "" (T.intercalate ",") . configServerCorsAllowedOrigins)
,("server-host", q . configServerHost)
,("server-port", show . configServerPort)
Expand Down Expand Up @@ -313,6 +315,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> parseOpenAPIMode "openapi-mode"
<*> (fromMaybe False <$> optBool "openapi-security-active")
<*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri"
<*> (fromMaybe False <$> optBool "openapi-metadata")
<*> parseCORSAllowedOrigins "server-cors-allowed-origins"
<*> (defaultServerHost <$> optString "server-host")
<*> parseServerPort "server-port"
Expand Down
2 changes: 1 addition & 1 deletion src/PostgREST/MainTx.hs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ actionResult MainQuery{mqOpenAPI=(tblsQ, funcsQ, schQ)} (MayUseDb plan@InspectPl
case configOpenApiMode of
OAFollowPriv -> do
tableAccess <- SQL.statement mempty $ SQL.dynamicallyParameterized tblsQ decodeAccessibleIdentifiers configDbPreparedStatements
accFuncs <- SQL.statement mempty $ SQL.dynamicallyParameterized funcsQ SchemaCache.decodeFuncs configDbPreparedStatements
accFuncs <- SQL.statement mempty $ SQL.dynamicallyParameterized funcsQ (SchemaCache.decodeFuncs configOpenApiMetadata) configDbPreparedStatements
schDesc <- SQL.statement mempty $ SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements
let tbls = HM.filterWithKey (\qi _ -> S.member qi tableAccess) $ SchemaCache.dbTables sCache

Expand Down
17 changes: 16 additions & 1 deletion src/PostgREST/Query/SqlFragment.hs
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,18 @@ baseFuncSqlQuery = SQL.sql $ encodeUtf8 [trimming|
WITH ORDINALITY AS _ (name, type, mode, idx)
WHERE type IS NOT NULL -- only input arguments
GROUP BY oid
),
return_columns AS (
-- OUT / TABLE return columns (mode 't'), name + pg_catalog short type.
SELECT
pp.oid,
array_agg((COALESCE(u.name, ''), rt.typname::text) ORDER BY u.idx)
FILTER (WHERE u.mode = 't') AS cols
FROM pg_proc pp
CROSS JOIN LATERAL unnest(pp.proallargtypes, pp.proargmodes, pp.proargnames)
WITH ORDINALITY AS u (typ_oid, mode, name, idx)
JOIN pg_type rt ON rt.oid = u.typ_oid
GROUP BY pp.oid
)
SELECT
pn.nspname AS proc_schema,
Expand All @@ -698,9 +710,12 @@ baseFuncSqlQuery = SQL.sql $ encodeUtf8 [trimming|
p.provolatile,
p.provariadic > 0 as hasvariadic,
'ignored' AS transaction_isolation_level,
'{}'::text[] as kvs
'{}'::text[] as kvs,
p.prorows::float8 as rows,
coalesce(rc.cols, '{}') as return_columns
FROM pg_proc p
LEFT JOIN arguments a ON a.oid = p.oid
LEFT JOIN return_columns rc ON rc.oid = p.oid
JOIN pg_namespace pn ON pn.oid = p.pronamespace
JOIN base_types bt ON bt.oid = p.prorettype
JOIN pg_type t ON t.oid = bt.base_type
Expand Down
237 changes: 227 additions & 10 deletions src/PostgREST/Response/OpenAPI.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ Module : PostgREST.OpenAPI
Description : Generates the OpenAPI output
-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.Response.OpenAPI (encode) where

import Data.Aeson ((.=))
import qualified Data.Aeson as JSON
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as HM
Expand All @@ -27,12 +31,15 @@ import PostgREST.Config (AppConfig (..), Proxy (..),
isMalformedProxyUri, toURI)
import PostgREST.MediaType
import PostgREST.Network (escapeHostName)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache (CompositeType (..),
ComputedField (..),
SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..),
Relationship (..),
RelationshipsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..),
PgType (..), RetType (..),
Routine (..),
RoutineParam (..))
import PostgREST.SchemaCache.Table (Column (..), Table (..),
Expand All @@ -43,15 +50,31 @@ import Protolude hiding (Proxy, get)

encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString
encode versions conf sCache tables procs schemaDescription =
JSON.encode $
postgrestSpec
versions
(dbRelationships sCache)
(concat $ HM.elems procs)
(snd <$> HM.toList tables)
(proxyUri conf)
schemaDescription
(configOpenApiSecurityActive conf)
JSON.encode withMetadata
where
tablesList = snd <$> HM.toList tables
routines = concat $ HM.elems procs
spec = JSON.toJSON $
postgrestSpec
versions
(dbRelationships sCache)
routines
tablesList
(proxyUri conf)
schemaDescription
(configOpenApiSecurityActive conf)
-- Opt-in: inject the typegen metadata block as a top-level vendor extension.
-- swagger2 has no typed extensions field, so we post-process the JSON Object.
withMetadata
| configOpenApiMetadata conf = case spec of
JSON.Object o ->
JSON.Object $
KM.insert (K.fromText "x-postgrest-typegen-metadata")
(typegenMetadata (dbRelationships sCache) routines tablesList
(dbCompositeTypes sCache) (dbComputedFields sCache))
o
other -> other
| otherwise = spec

makeMimeList :: [MediaType] -> MimeList
makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs
Expand Down Expand Up @@ -452,3 +475,197 @@ proxyUri AppConfig{..} =
(proxyScheme, proxyHost, proxyPort, proxyPath)
Nothing ->
("http", configServerHost, toInteger configServerPort, "/")

-- | Build the `x-postgrest-typegen-metadata` block: a name-keyed projection of
-- the schema cache mirroring `@supabase/postgrest-typegen`'s GeneratorMetadata,
-- so types can be generated from the OpenAPI document alone. Opt-in via
-- `openapi-metadata`. Type names are emitted as pg_catalog short names
-- (int8, _text, user_status) to match the SQL introspection path.
typegenMetadata :: RelationshipsMap -> [Routine] -> [Table] -> [CompositeType] -> [ComputedField] -> JSON.Value
typegenMetadata relsMap routines tbls comps cfields = JSON.object
[ "schemas" .= [ JSON.object ["name" .= s] | s <- nubOn identity (map tableSchema tbls) ]
, "tables" .= map tableJ tbls
, "relationships" .= concatMap relJ allRels
, "functions" .= map snd (nubOn fst funcEntries)
, "composites" .= map compJ comps
, "enums" .= map enumJ enumsList
]
where
-- The RelationshipsMap indexes each relationship under multiple keys, so
-- flattening yields duplicates — dedupe before emitting.
allRels = nubOn relKey (concat (HM.elems relsMap))
relKey r = case r of
Relationship{relTable, relForeignTable, relCardinality} ->
T.intercalate "|"
[ "rel", qiSchema relTable, qiName relTable
, qiSchema relForeignTable, qiName relForeignTable, cardKey relCardinality ]
ComputedRelationship{relFunction, relTable, relForeignTable} ->
T.intercalate "|"
[ "crel", qiSchema relFunction, qiName relFunction
, qiName relTable, qiName relForeignTable ]
colsKey cols = T.intercalate "," (map fst cols) <> ">" <> T.intercalate "," (map snd cols)
cardKey c = case c of
M2O cons cols -> "m2o|" <> cons <> "|" <> colsKey cols
O2O cons cols _ -> "o2o|" <> cons <> "|" <> colsKey cols
O2M cons cols -> "o2m|" <> cons <> "|" <> colsKey cols
M2M _ -> "m2m"

enumsList = nubOn (\(_, n, _) -> n)
[ (tableSchema t, colFormat c, colEnum c)
| t <- tbls, c <- tableColumnsList t, not (null (colEnum c)) ]
enumJ (s, n, vs) = JSON.object ["schema" .= s, "name" .= n, "values" .= vs]

tableJ t = JSON.object
[ "schema" .= tableSchema t
, "name" .= tableName t
, "kind" .= tableKind t
, "updatable" .= tableUpdatable t -- drives view Insert/Update generation
, "columns" .= map (colJ (tableUpdatable t)) (tableColumnsList t)
]

-- Per-column updatability isn't tracked by PostgREST; fall back to the
-- relation-level flag (correct for tables and non-updatable views).
colJ upd c = JSON.object
[ "name" .= colName c
, "format" .= colFormat c
, "data_type" .= colType c
, "is_nullable" .= colNullable c
, "default_value" .= colDefault c
, "is_identity" .= isJust (colIdentityGeneration c)
, "identity_generation" .= colIdentityGeneration c
, "is_generated" .= colIsGenerated c
, "is_updatable" .= upd
, "enums" .= colEnum c
, "comment" .= colDescription c
]

relJ rel = case rel of
Relationship{relTable, relForeignTable, relCardinality} ->
case relCardinality of
M2O cons cols -> [mkRel relTable relForeignTable cons cols False]
-- Only the FK-owner side (isParent = False, per decodeRels). The
-- inverse O2O (isParent = True), the O2M inverse, and M2M are not
-- emitted as relationships by introspect().
O2O cons cols isParent -> [mkRel relTable relForeignTable cons cols True | not isParent]
O2M _ _ -> []
M2M _ -> []
ComputedRelationship{} -> []

mkRel src tgt cons cols oneToOne = JSON.object
[ "constraint_name" .= cons
, "schema" .= qiSchema src
, "relation" .= qiName src
, "columns" .= map fst cols
, "is_one_to_one" .= oneToOne
, "referenced_schema" .= qiSchema tgt
, "referenced_relation" .= qiName tgt
, "referenced_columns" .= map snd cols
]

routineJ pd = JSON.object
[ "schema" .= pdSchema pd
, "name" .= pdName pd
, "argument_types" .= routineArgTypes pd
-- input args + OUT/TABLE return columns (mode "table") so the generator
-- can render `RETURNS TABLE(...)` columns instead of falling back to record.
, "args" .= (map argJ (pdParams pd) ++ map tableColArg (pdReturnColumns pd))
, "return" .= retJ (pdReturnType pd) (pdRows pd)
, "volatility" .= volJ (pdVolatility pd)
]
tableColArg (cname, ctype) = JSON.object
[ "name" .= cname, "type" .= ctype, "mode" .= ("table" :: Text), "has_default" .= False ]
argJ p = JSON.object
[ "name" .= ppName p
, "type" .= shortType (ppType p)
, "mode" .= (if ppVar p then "variadic" else "in" :: Text)
, "has_default" .= not (ppReq p)
]
retJ rt rows = case rt of
Single pgt -> retObj pgt False rows
SetOf pgt -> retObj pgt True rows
retObj pgt isSet rows = JSON.object $
[ "type" .= pgTypeName pgt
, "is_set" .= isSet
, "rows" .= rows
] ++ [ "relation" .= relationOf pgt ]
relationOf pgt = case pgt of
Composite qi _ -> JSON.object ["schema" .= qiSchema qi, "name" .= qiName qi]
Scalar _ -> JSON.Null
pgTypeName pgt = case pgt of
Composite qi _ -> qiName qi
Scalar qi -> qiName qi

cfieldJ cf = JSON.object
[ "schema" .= cfSchema cf
, "name" .= cfName cf
, "argument_types" .= cfTableName cf
, "args" .= [ tableRowArg (cfTableName cf) ]
, "return" .= JSON.object
[ "type" .= cfReturnType cf
, "is_set" .= cfIsSetOf cf
, "rows" .= cfRows cf
, "relation" .= if cfReturnIsRelation cf
then JSON.object ["schema" .= cfReturnSchema cf, "name" .= cfReturnType cf]
else JSON.Null
]
, "volatility" .= volJ (cfVolatility cf)
]

-- All emitted functions, keyed by (schema, name, argument_types) and deduped
-- so a single named table-row arg function (present in both dbRoutines and
-- the computed-field/relationship sources) is emitted once. RPC routines win.
funcEntries =
[ ((pdSchema pd, pdName pd, routineArgTypes pd), routineJ pd) | pd <- routines ]
++ [ ((cfSchema cf, cfName cf, cfTableName cf), cfieldJ cf)
| cf <- cfields
, (cfSchema cf, cfName cf, cfTableName cf) `notElem` routineSingleArgSigs ]
-- (schema, name, single-arg-type) of callable routines with exactly one arg:
-- used to skip the computed-field/relationship copy of a function that is
-- already a callable routine (introspect() lists it once, as the routine).
routineSingleArgSigs =
[ (pdSchema pd, pdName pd, shortType (ppType p))
| pd <- routines, [p] <- [pdParams pd] ]
-- Mirror introspect()'s argument_types: bare type for unnamed args, "name
-- type" for named ones, so only unnamed single-table-arg functions match a
-- table name and attach as computed fields.
routineArgTypes pd = T.intercalate ", "
[ if ppName p == "" then shortType (ppType p) else ppName p <> " " <> shortType (ppType p)
| p <- pdParams pd ]
tableRowArg tname = JSON.object
[ "name" .= ("" :: Text), "type" .= tname, "mode" .= ("in" :: Text), "has_default" .= False ]

compJ ct = JSON.object
[ "schema" .= ctSchema ct
, "name" .= ctName ct
, "attributes" .= [ JSON.object ["name" .= n, "type" .= ty] | (n, ty) <- ctAttributes ct ]
]

volJ v = case v of
Immutable -> "IMMUTABLE" :: Text
Stable -> "STABLE"
Volatile -> "VOLATILE"

-- | Distinct-by-key, preserving first occurrence.
nubOn :: Eq b => (a -> b) -> [a] -> [a]
nubOn f = go []
where
go _ [] = []
go seen (x:xs)
| f x `elem` seen = go seen xs
| otherwise = x : go (f x : seen) xs

-- | Map a pg regtype text (SQL spelling, possibly schema-qualified) to the
-- pg_catalog short type name the typegen generator expects (int8, _text, …).
shortType :: Text -> Text
shortType raw
| "[]" `T.isSuffixOf` raw = "_" <> shortType (T.dropEnd 2 raw)
| otherwise = maybe bare snd (find ((== bare) . fst) sqlToShort)
where
bare = T.takeWhileEnd (/= '.') raw
sqlToShort =
[ ("integer", "int4"), ("bigint", "int8"), ("smallint", "int2")
, ("boolean", "bool"), ("double precision", "float8"), ("real", "float4")
, ("character varying", "varchar"), ("character", "bpchar")
, ("timestamp with time zone", "timestamptz")
, ("timestamp without time zone", "timestamp")
, ("time with time zone", "timetz"), ("time without time zone", "time") ]
Comment on lines +648 to +671

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move this Haskell logic to SQL?

@laurenceisla laurenceisla Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are Postgres types, right? Then yeah, I think what this is doing is taking the long names that PostgREST retrieves from PostgreSQL and then converting them to short names. So there's no need to do this transformation if the schema cache query also takes the short names alongside the long names.

Loading