diff --git a/.github/workflows/gen-openapi.yml b/.github/workflows/openapi.yml similarity index 96% rename from .github/workflows/gen-openapi.yml rename to .github/workflows/openapi.yml index bd66644e..ea88978a 100644 --- a/.github/workflows/gen-openapi.yml +++ b/.github/workflows/openapi.yml @@ -78,13 +78,12 @@ jobs: sleep 2 done - - name: Run app + - name: Run generator env: DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline CORE_SECRET: ${{ steps.secret.outputs.secret }} - ZIPLINE_OUTPUT_OPENAPI: true - - run: pnpm start + NODE_ENV: production + run: pnpm openapi - name: Verify openapi.json exists run: | diff --git a/package.json b/package.json index 1fc1d62c..d1a976d6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server", "ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl", "validate": "tsx scripts/validate.ts", + "openapi": "tsx scripts/openapi.ts", "db:prototype": "prisma db push --skip-generate && prisma generate --no-hints", "db:migrate": "prisma migrate dev --create-only", "docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w", diff --git a/scripts/index.ts b/scripts/index.ts index 78a18304..37b83a69 100644 --- a/scripts/index.ts +++ b/scripts/index.ts @@ -1,4 +1,6 @@ -export function step(name: string, command: string, condition: () => boolean = () => true) { +type StepCommand = string | (() => void | Promise); + +export function step(name: string, command: StepCommand, condition: () => boolean = () => true) { return { name, command, @@ -35,7 +37,11 @@ export async function run(name: string, ...steps: Step[]) { try { log(`> Running step "${name}/${step.name}"...`); - execSync(step.command, { stdio: 'inherit' }); + if (typeof step.command === 'string') { + execSync(step.command, { stdio: 'inherit' }); + } else { + await step.command(); + } } catch { console.error(`x Step "${name}/${step.name}" failed.`); process.exit(1); diff --git a/scripts/openapi.ts b/scripts/openapi.ts new file mode 100644 index 00000000..e8898735 --- /dev/null +++ b/scripts/openapi.ts @@ -0,0 +1,110 @@ +import { readFile, writeFile } from 'fs/promises'; +import path from 'path'; +import { run, step } from '.'; +import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors'; + +const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put']; +const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json'); + +const ALL_ERRORS = Object.keys(API_ERRORS) + .map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON()) + .sort((a, b) => a.code - b.code); + +const ERROR_SCHEMA = { + type: 'object', + description: 'Generic error for API endpoints.', + properties: { + error: { + type: 'string', + description: + 'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.', + }, + code: { + type: 'integer', + format: 'int32', + description: + 'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.', + enum: ALL_ERRORS.map((entry) => entry.code), + 'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message), + }, + statusCode: { + type: 'integer', + format: 'int32', + description: 'HTTP status code returned alongside this error payload.', + }, + }, + required: ['error', 'code', 'statusCode'], + additionalProperties: true, +}; + +const ERROR_EXAMPLES = ALL_ERRORS.reduce>((examples, entry) => { + examples[`E${entry.code}`] = { + summary: `${entry.error}`, + value: entry, + }; + + return examples; +}, {}); + +const generic4xxResponse = { + description: 'API error response (4xx)', + content: { + 'application/json': { + schema: ERROR_SCHEMA, + examples: ERROR_EXAMPLES, + }, + }, +}; + +function addErrorResponse(responses: Record): void { + const response = (responses['4xx'] ??= structuredClone(generic4xxResponse)); + + response.description ??= generic4xxResponse.description; + response.content ??= {}; + + const jsonContent = (response.content['application/json'] ??= {}); + jsonContent.schema ??= structuredClone(ERROR_SCHEMA); + jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples); +} + +function filterRoutes(paths = {}): Record { + return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api'))); +} + +async function fixSpec() { + const spec = JSON.parse(await readFile(GEN_PATH, 'utf8')); + + spec.paths = filterRoutes(spec.paths); + + for (const [, pathItem] of Object.entries(spec.paths ?? {})) { + if (!pathItem) continue; + + for (const method of ALL_METHODS) { + const operation = (pathItem)[method]; + if (!operation) continue; + + operation.responses ??= {}; + addErrorResponse(operation.responses); + } + } + + await writeFile(GEN_PATH, JSON.stringify(spec)); +} + +process.env.ZIPLINE_OUTPUT_OPENAPI = 'true'; + +run( + 'openapi', + step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'), + step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'), + step('check', async () => { + try { + await readFile(GEN_PATH); + } catch (e) { + console.error('\nSomething went wrong...', e); + + throw new Error('No OpenAPI spec found at ./openapi.json'); + } + }), + step('fix', fixSpec), +); diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index 81add390..cfafc86a 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -148,7 +148,7 @@ export class ApiError extends Error { toJSON(): ApiErrorPayload { const formattedMessage = API_ERRORS[this.code] - ? `${this.code}${this.message ? `: ${this.message}` : ''}` + ? `E${this.code}${this.message ? `: ${this.message}` : ''}` : this.message; return { @@ -163,7 +163,7 @@ export class ApiError extends Error { return payload.code === code; } - private static codeToHttpStatus(code: ApiErrorCode): number { + public static codeToHttpStatus(code: ApiErrorCode): number { const override = { 9000: 400, 9001: 403,