diff --git a/README.md b/README.md index 5d02ae5a..b7f034b8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Helpers: - [ ] GET `/typescript`: Generate Typescript types - [ ] GET `/swift`: Generate Swift types (beta) - [ ] GET `/python`: Generate Python types (beta) + - [ ] GET `/json`: Dump the raw generator metadata as JSON, for third-party type generators ## Quickstart @@ -110,6 +111,7 @@ where `` is one of: - `go` - `swift` (beta) - `python` (beta) +- `json` (the raw generator metadata all the other generators consume, for building your own generator) To use your own database connection string instead of the provided test database, run: `PG_META_DB_URL=postgresql://postgres:postgres@localhost:5432/postgres npm run gen:types:` diff --git a/package.json b/package.json index 1ac8e93a..96f0913f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "gen:types:go": "PG_META_GENERATE_TYPES=go node --loader ts-node/esm src/server/server.ts", "gen:types:swift": "PG_META_GENERATE_TYPES=swift node --loader ts-node/esm src/server/server.ts", "gen:types:python": "PG_META_GENERATE_TYPES=python node --loader ts-node/esm src/server/server.ts", + "gen:types:json": "PG_META_GENERATE_TYPES=json node --loader ts-node/esm src/server/server.ts", "start": "node dist/server/server.js", "dev": "trap 'npm run db:clean' INT && run-s db:clean db:run && run-s dev:code", "dev:code": "nodemon --exec node --loader ts-node/esm src/server/server.ts | pino-pretty --colorize", diff --git a/src/server/routes/generators/json.ts b/src/server/routes/generators/json.ts new file mode 100644 index 00000000..f1f27be8 --- /dev/null +++ b/src/server/routes/generators/json.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from 'fastify' +import { PostgresMeta } from '../../../lib/index.js' +import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' +import { apply as applyJsonTemplate } from '../../templates/json.js' +import { getGeneratorMetadata } from '../../../lib/generators.js' + +export default async (fastify: FastifyInstance) => { + fastify.get<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + Querystring: { + excluded_schemas?: string + included_schemas?: string + } + }>('/', async (request, reply) => { + const config = createConnectionConfig(request) + const excludedSchemas = + request.query.excluded_schemas?.split(',').map((schema) => schema.trim()) ?? [] + const includedSchemas = + request.query.included_schemas?.split(',').map((schema) => schema.trim()) ?? [] + + const pgMeta: PostgresMeta = new PostgresMeta(config) + const { data: generatorMeta, error: generatorMetaError } = await getGeneratorMetadata(pgMeta, { + includedSchemas, + excludedSchemas, + }) + if (generatorMetaError) { + request.log.error({ error: generatorMetaError, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: generatorMetaError.message } + } + + return reply.type('application/json').send(applyJsonTemplate(generatorMeta)) + }) +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 46ffba0f..99b49147 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -22,6 +22,7 @@ import TypeScriptTypeGenRoute from './generators/typescript.js' import GoTypeGenRoute from './generators/go.js' import SwiftTypeGenRoute from './generators/swift.js' import PythonTypeGenRoute from './generators/python.js' +import JsonTypeGenRoute from './generators/json.js' import { PG_CONNECTION, CRYPTO_KEY } from '../constants.js' export default async (fastify: FastifyInstance) => { @@ -84,4 +85,5 @@ export default async (fastify: FastifyInstance) => { fastify.register(GoTypeGenRoute, { prefix: '/generators/go' }) fastify.register(SwiftTypeGenRoute, { prefix: '/generators/swift' }) fastify.register(PythonTypeGenRoute, { prefix: '/generators/python' }) + fastify.register(JsonTypeGenRoute, { prefix: '/generators/json' }) } diff --git a/src/server/server.ts b/src/server/server.ts index 785e293d..9e34397c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -21,6 +21,7 @@ import { apply as applyTypescriptTemplate } from './templates/typescript.js' import { apply as applyGoTemplate } from './templates/go.js' import { apply as applySwiftTemplate } from './templates/swift.js' import { apply as applyPythonTemplate } from './templates/python.js' +import { apply as applyJsonTemplate } from './templates/json.js' const logger = pino({ formatters: { @@ -148,6 +149,8 @@ async function getTypeOutput(): Promise { return applyGoTemplate(config) case 'python': return applyPythonTemplate(config) + case 'json': + return applyJsonTemplate(config) default: throw new Error(`Unsupported language for GENERATE_TYPES: ${GENERATE_TYPES}`) } diff --git a/src/server/templates/json.ts b/src/server/templates/json.ts new file mode 100644 index 00000000..08c93d04 --- /dev/null +++ b/src/server/templates/json.ts @@ -0,0 +1,35 @@ +import type { GeneratorMetadata } from '../../lib/generators.js' + +// Bump when the shape of the emitted document changes in a way consumers +// must detect (field removals, renames, or semantic changes). Additive +// changes are backwards compatible and do not require a bump. +export const JSON_SCHEMA_VERSION = 1 + +export const apply = ({ + schemas, + tables, + foreignTables, + views, + materializedViews, + columns, + relationships, + functions, + types, +}: GeneratorMetadata): string => { + return JSON.stringify( + { + version: JSON_SCHEMA_VERSION, + schemas, + tables, + foreignTables, + views, + materializedViews, + columns, + relationships, + functions, + types, + }, + null, + 2 + ) +} diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 0ce7c582..dfa441d0 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -6996,3 +6996,98 @@ test('typegen: python w/ excluded/included schemas', async () => { }) } }) + +test('typegen: json', async () => { + const response = await app.inject({ method: 'GET', path: '/generators/json' }) + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toContain('application/json') + + const metadata = JSON.parse(response.body) + expect(metadata.version).toBe(1) + expect(Object.keys(metadata)).toMatchInlineSnapshot(` + [ + "version", + "schemas", + "tables", + "foreignTables", + "views", + "materializedViews", + "columns", + "relationships", + "functions", + "types", + ] + `) + + // The whole point of this endpoint is introspection fidelity that lossy + // sources (like the PostgREST OpenAPI description) cannot provide, so + // assert that nullability, defaults, and identity information survive. + const usersStatus = metadata.columns.find( + (column: any) => + column.schema === 'public' && column.table === 'users' && column.name === 'status' + ) + expect(usersStatus).toMatchObject({ + is_nullable: true, + default_value: "'ACTIVE'::user_status", + data_type: 'USER-DEFINED', + format: 'user_status', + }) + + const usersId = metadata.columns.find( + (column: any) => column.schema === 'public' && column.table === 'users' && column.name === 'id' + ) + expect(usersId).toMatchObject({ + is_nullable: false, + is_identity: true, + identity_generation: 'BY DEFAULT', + }) + + const todosUserId = metadata.columns.find( + (column: any) => + column.schema === 'public' && column.table === 'todos' && column.name === 'user-id' + ) + expect(todosUserId).toMatchObject({ + is_nullable: false, + default_value: null, + }) + + const userStatusEnum = metadata.types.find( + (type: any) => type.schema === 'public' && type.name === 'user_status' + ) + expect(userStatusEnum.enums).toEqual(['ACTIVE', 'INACTIVE']) + + const todosView = metadata.views.find( + (view: any) => view.schema === 'public' && view.name === 'todos_view' + ) + expect(todosView).toBeDefined() + + const todosMatview = metadata.materializedViews.find( + (view: any) => view.schema === 'public' && view.name === 'todos_matview' + ) + expect(todosMatview).toBeDefined() + + const addFunction = metadata.functions.find( + (fn: any) => fn.schema === 'public' && fn.name === 'add' + ) + expect(addFunction).toMatchObject({ return_type: 'integer' }) +}) + +test('typegen: json w/ excluded/included schemas', async () => { + const { body: excludedBody } = await app.inject({ + method: 'GET', + path: '/generators/json', + query: { excluded_schemas: 'public' }, + }) + const excludedMetadata = JSON.parse(excludedBody) + expect(excludedMetadata.tables).toEqual([]) + expect(excludedMetadata.schemas.map((schema: any) => schema.name)).not.toContain('public') + + const { body: includedBody } = await app.inject({ + method: 'GET', + path: '/generators/json', + query: { included_schemas: 'public' }, + }) + const includedMetadata = JSON.parse(includedBody) + expect(includedMetadata.schemas.map((schema: any) => schema.name)).toEqual(['public']) + expect(includedMetadata.tables.map((table: any) => table.name)).toContain('users') +})