-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add x-postgrest-typegen-metadata to openapi #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -204,3 +204,7 @@ wfts | |
| www | ||
| debouncing | ||
| deduplicates | ||
| cardinality | ||
| lossy | ||
| Typegen | ||
| updatability | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 (..), | ||
|
|
@@ -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 | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we move this Haskell logic to SQL? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
There was a problem hiding this comment.
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-metadataour only option for this new metadata?There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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-metadataand regular OpenAPI/JSONSchema likenullable,enum,default, etc. for tables and views (and some others for functions). So, an alternative would be to add thesex-<my-custom-field>to each Object (parameter, path, etc.), similar to what's proposed here. However therelationshipsfield 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-metadatais 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.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From a comment there:
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.