ci(api): verify generated types against production (#49993)

This commit is contained in:
Jordi Enric authored and GitHub committed 2026-09-10 08:51:24 +02:00
1 parent c708e1128f
commit 84db103ebb
8 files changed
+290 -8

No files matched your search

+3 -1
View File
@@ -7,7 +7,9 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"clean": "rimraf .turbo tsconfig.tsbuildinfo",
"codegen": "openapi-typescript --redocly ./redocly.yaml --alphabetize --default-non-nullable=false && prettier --cache --write types/*.d.ts"
"codegen": "openapi-typescript --redocly ./redocly.yaml --alphabetize --default-non-nullable=false && prettier --cache --write types/*.d.ts",
"verify-production-types": "node ./scripts/verify-production-types.mjs",
"test": "node --test ./scripts/*.test.mjs"
},
"author": "",
"license": "MIT",
@@ -0,0 +1,121 @@
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
const run = promisify(execFile)
const packageDirectory = dirname(dirname(fileURLToPath(import.meta.url)))
const typesDirectory = process.env.API_TYPES_DIRECTORY ?? join(packageDirectory, 'types')
const platformApiUrl =
process.env.PLATFORM_API_OPENAPI_URL ?? 'https://api.supabase.com/api/platform-json'
const fetchTimeout = 30_000
const specifications = [
{ name: 'api-v1', url: 'https://api.supabase.com/api/v1-json' },
{ name: 'api-v2', url: 'https://api.supabase.com/api/v2-json' },
{ name: 'platform', url: platformApiUrl },
]
export async function fetchOpenApiSpecifications(
specifications,
{ fetchImpl = fetch, writeFileImpl = writeFile, temporaryDirectory, generatedTypesDirectory }
) {
return Promise.all(
specifications.map(async ({ name, url }) => {
let response
try {
response = await fetchImpl(url, {
signal: AbortSignal.timeout(fetchTimeout),
})
} catch {
throw new Error(`Could not fetch ${name} OpenAPI specification from ${url}.`)
}
if (!response.ok) {
throw new Error(
`Could not fetch ${name} OpenAPI specification from ${url}: ${response.status}.`
)
}
await writeFileImpl(join(temporaryDirectory, `${name}.json`), await response.text())
return ` ${name}:\n root: ${join(temporaryDirectory, `${name}.json`)}\n x-openapi-ts:\n output: ${join(generatedTypesDirectory, `${name}.d.ts`)}`
})
)
}
export async function findMismatchedTypes(
specifications,
{ readFileImpl = readFile, generatedTypesDirectory, typesDirectory }
) {
const mismatches = await Promise.all(
specifications.map(async ({ name }) => {
const filename = `${name}.d.ts`
const [generated, committed] = await Promise.all([
readFileImpl(join(generatedTypesDirectory, filename), 'utf8'),
readFileImpl(join(typesDirectory, filename), 'utf8'),
])
return generated === committed ? undefined : filename
})
)
return mismatches.filter((filename) => filename !== undefined)
}
export async function verifyProductionTypes() {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'api-types-'))
const generatedTypesDirectory = join(temporaryDirectory, 'types')
try {
await mkdir(generatedTypesDirectory)
const config = await fetchOpenApiSpecifications(specifications, {
temporaryDirectory,
generatedTypesDirectory,
})
await writeFile(join(temporaryDirectory, 'redocly.yaml'), `apis:\n${config.join('\n')}`)
await run(
'pnpm',
[
'exec',
'openapi-typescript',
'--redocly',
join(temporaryDirectory, 'redocly.yaml'),
'--alphabetize',
'--default-non-nullable=false',
],
{ cwd: packageDirectory }
)
await run(
'pnpm',
[
'exec',
'prettier',
'--write',
...specifications.map(({ name }) => join(generatedTypesDirectory, `${name}.d.ts`)),
],
{ cwd: packageDirectory }
)
const changedTypes = await findMismatchedTypes(specifications, {
generatedTypesDirectory,
typesDirectory,
})
if (changedTypes.length > 0) {
throw new Error(`Committed API types do not match production: ${changedTypes.join(', ')}`)
}
} finally {
await rm(temporaryDirectory, { force: true, recursive: true })
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await verifyProductionTypes()
}
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { fetchOpenApiSpecifications, findMismatchedTypes } from './verify-production-types.mjs'
const specifications = [
{ name: 'api-v1', url: 'https://example.com/api/v1-json' },
{ name: 'platform', url: 'https://example.com/api/platform-json' },
]
test('writes fetched specifications and returns their Redocly configuration', async () => {
const writes = []
const config = await fetchOpenApiSpecifications(specifications, {
temporaryDirectory: '/tmp/api-types',
generatedTypesDirectory: '/tmp/api-types/types',
fetchImpl: async (url) => ({
ok: true,
text: async () => `OpenAPI specification from ${url}`,
}),
writeFileImpl: async (path, content) => writes.push({ path, content }),
})
assert.deepEqual(writes, [
{
path: '/tmp/api-types/api-v1.json',
content: 'OpenAPI specification from https://example.com/api/v1-json',
},
{
path: '/tmp/api-types/platform.json',
content: 'OpenAPI specification from https://example.com/api/platform-json',
},
])
assert.deepEqual(config, [
' api-v1:\n root: /tmp/api-types/api-v1.json\n x-openapi-ts:\n output: /tmp/api-types/types/api-v1.d.ts',
' platform:\n root: /tmp/api-types/platform.json\n x-openapi-ts:\n output: /tmp/api-types/types/platform.d.ts',
])
})
test('returns no mismatches when generated types match committed types', async () => {
const mismatches = await findMismatchedTypes(specifications, {
generatedTypesDirectory: '/generated',
typesDirectory: '/committed',
readFileImpl: async (path) =>
path.includes('/generated/') ? 'generated type' : 'generated type',
})
assert.deepEqual(mismatches, [])
})
test('reports every generated type that differs from its committed counterpart', async () => {
const mismatches = await findMismatchedTypes(specifications, {
generatedTypesDirectory: '/generated',
typesDirectory: '/committed',
readFileImpl: async (path) => {
if (path.endsWith('api-v1.d.ts')) return 'matching type'
return path.includes('/generated/') ? 'new platform type' : 'committed platform type'
},
})
assert.deepEqual(mismatches, ['platform.d.ts'])
})
test('rejects unsuccessful OpenAPI responses', async () => {
await assert.rejects(
fetchOpenApiSpecifications([specifications[0]], {
temporaryDirectory: '/tmp/api-types',
generatedTypesDirectory: '/tmp/api-types/types',
fetchImpl: async () => ({ ok: false, status: 503 }),
writeFileImpl: async () => assert.fail('does not write an unsuccessful response'),
}),
/Could not fetch api-v1 OpenAPI specification from https:\/\/example\.com\/api\/v1-json: 503\./
)
})
test('rejects failed OpenAPI requests', async () => {
await assert.rejects(
fetchOpenApiSpecifications([specifications[0]], {
temporaryDirectory: '/tmp/api-types',
generatedTypesDirectory: '/tmp/api-types/types',
fetchImpl: async () => {
throw new Error('network unavailable')
},
writeFileImpl: async () => assert.fail('does not write a failed response'),
}),
/Could not fetch api-v1 OpenAPI specification from https:\/\/example\.com\/api\/v1-json\./
)
})