mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
ci(api): verify generated types against production (#49993)
This commit is contained in:
1 parent
c708e1128f
commit
84db103ebb
8 files changed
+290
-8
No files matched your search
@@ -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\./
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user