diff --git a/packages/contracts/README.md b/packages/contracts/README.md deleted file mode 100644 index 0325210598..0000000000 --- a/packages/contracts/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Contracts - -## API OpenAPI Readiness - - - - - -Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`. - -Are we OpenAPI ready? **No.** Current generated API contracts are **35.4% ready**. - -| Surface | Ready | Not ready | Total | Ready % | -| --------- | ------: | --------: | ------: | --------: | -| console | 205 | 383 | 588 | 34.9% | -| service | 28 | 60 | 88 | 31.8% | -| web | 21 | 20 | 41 | 51.2% | -| **total** | **254** | **463** | **717** | **35.4%** | - -Readiness here means the generated contract operation is not marked with: - -> Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - -Operations marked with that warning should not be migrated to blindly. Prefer fixing backend OpenAPI annotations first so the generated contract has accurate request and response types, then migrate callers endpoint by endpoint. - -The current heuristic marks an operation as not ready when a request body or success response that should have a body contains a loose object type, when a mutating controller reads a JSON body that is not documented as a request body, or when an operation has no documented 2xx response. 204, 205, and 304 responses are treated as bodyless when the request type is otherwise accurate. - - - -## How to Improve Readiness - -Improve the ready percentage by fixing the backend annotations that produce loose generated types, then regenerating the contracts. - -- Add accurate request body schemas for endpoints that currently generate loose object types. -- Add accurate 2xx response schemas for endpoints that return JSON payloads. -- Use 204 responses for endpoints that intentionally return no body. -- Avoid untyped dictionaries, raw objects, or `additionalProperties: true` responses unless the API really returns an arbitrary object. -- Regenerate with `pnpm -C packages/contracts gen-api-contract` and use this README to verify the updated percentage. - -Do not remove the generated warning just to increase the number. The warning should disappear because the backend OpenAPI output became accurate enough for callers to migrate safely. diff --git a/packages/contracts/generated/api/readiness.json b/packages/contracts/generated/api/readiness.json deleted file mode 100644 index 0497f9a7ba..0000000000 --- a/packages/contracts/generated/api/readiness.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "surfaces": { - "console": { - "notReady": 383, - "total": 588 - }, - "service": { - "notReady": 60, - "total": 88 - }, - "web": { - "notReady": 20, - "total": 41 - } - }, - "warning": "Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate." -} diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 5cb46dd496..ccf838880a 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -90,7 +90,6 @@ type ApiOperationContext = { const currentDir = path.dirname(fileURLToPath(import.meta.url)) const apiOpenApiDir = path.resolve(currentDir, 'openapi') -const apiReadinessStatsPath = path.resolve(currentDir, 'generated/api/readiness.json') const apiControllersDir = path.resolve(currentDir, '../../api/controllers') const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) @@ -750,6 +749,10 @@ const recordApiReadiness = (surface: string, isReady: boolean) => { stats.notReady += 1 } +const formatPercent = (ready: number, total: number) => { + return total === 0 ? '0.0%' : `${((ready / total) * 100).toFixed(1)}%` +} + const normalizeOperations = (document: SwaggerDocument, surface: string) => { const definitions = document.definitions ??= {} @@ -792,18 +795,29 @@ const normalizeApiSwagger = (document: SwaggerDocument, surface: string) => { return document } -const writeApiReadinessStats = () => { +const printApiReadinessStats = () => { const sortedSurfaces = Object.entries(apiReadinessStats) .sort(([left], [right]) => left.localeCompare(right)) - fs.mkdirSync(path.dirname(apiReadinessStatsPath), { recursive: true }) - fs.writeFileSync( - apiReadinessStatsPath, - `${JSON.stringify({ - surfaces: Object.fromEntries(sortedSurfaces), - warning: inaccurateGeneratedContractDescription, - }, null, 2)}\n`, + const totals = sortedSurfaces.reduce( + (summary, [, stats]) => { + summary.notReady += stats.notReady + summary.total += stats.total + return summary + }, + { notReady: 0, total: 0 }, ) + const totalReady = totals.total - totals.notReady + const rows = sortedSurfaces.map(([surface, stats]) => { + const ready = stats.total - stats.notReady + return ` ${surface}: ${ready}/${stats.total} ready (${formatPercent(ready, stats.total)}), ${stats.notReady} not ready` + }) + + console.log([ + 'API OpenAPI readiness:', + ...rows, + ` total: ${totalReady}/${totals.total} ready (${formatPercent(totalReady, totals.total)}), ${totals.notReady} not ready`, + ].join('\n')) } const topLevelPathSegment = (routePath: string) => { @@ -933,7 +947,7 @@ const createApiJobs = (spec: ApiSpec): ApiJob[] => { } const apiJobs = apiSpecs.flatMap(createApiJobs) -writeApiReadinessStats() +printApiReadinessStats() const createApiConfig = (job: ApiJob): UserConfig => ({ input: job.document, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 507a95a779..c2dd43e990 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -15,9 +15,8 @@ }, "scripts": { "gen-api-contract": "pnpm gen-api-openapi && pnpm gen-api-contract-from-openapi", - "gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api && pnpm gen-api-readiness-readme", + "gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api", "gen-api-openapi": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi", - "gen-api-readiness-readme": "node scripts/generate-api-readiness-readme.mjs && eslint --fix README.md", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", "type-check": "tsgo" }, diff --git a/packages/contracts/scripts/generate-api-readiness-readme.mjs b/packages/contracts/scripts/generate-api-readiness-readme.mjs deleted file mode 100644 index d063565aed..0000000000 --- a/packages/contracts/scripts/generate-api-readiness-readme.mjs +++ /dev/null @@ -1,94 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const currentDir = path.dirname(fileURLToPath(import.meta.url)) -const packageDir = path.resolve(currentDir, '..') -const readinessStatsPath = path.resolve(packageDir, 'generated/api/readiness.json') -const readmePath = path.resolve(packageDir, 'README.md') - -const readinessStartMarker = '' -const readinessEndMarker = '' - -const formatPercent = (ready, total) => { - return total === 0 ? '0.0%' : `${((ready / total) * 100).toFixed(1)}%` -} - -const collectStats = () => { - if (!fs.existsSync(readinessStatsPath)) { - throw new Error( - `Missing API readiness stats: ${readinessStatsPath}. Run "pnpm -C packages/contracts gen-api-contract-from-openapi" first.`, - ) - } - - return JSON.parse(fs.readFileSync(readinessStatsPath, 'utf8')) -} - -const tableRow = (surface, ready, notReady, total) => { - return `| ${surface} | ${ready} | ${notReady} | ${total} | ${formatPercent(ready, total)} |` -} - -const renderReadinessSection = (stats) => { - const rows = Object.entries(stats.surfaces) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([surface, stat]) => tableRow(surface, stat.total - stat.notReady, stat.notReady, stat.total)) - - const totals = Object.values(stats.surfaces).reduce( - (summary, stat) => { - summary.notReady += stat.notReady - summary.total += stat.total - return summary - }, - { notReady: 0, total: 0 }, - ) - const totalReady = totals.total - totals.notReady - - if (totals.total === 0) - throw new Error(`No API readiness stats found in ${readinessStatsPath}`) - - return `${readinessStartMarker} - - - -Snapshot generated from \`packages/contracts/generated/api/readiness.json\` after running \`pnpm -C packages/contracts gen-api-contract-from-openapi\`. - -Are we OpenAPI ready? **No.** Current generated API contracts are **${formatPercent(totalReady, totals.total)} ready**. - -| Surface | Ready | Not ready | Total | Ready % | -| --- | ---: | ---: | ---: | ---: | -${rows.join('\n')} -| **total** | **${totalReady}** | **${totals.notReady}** | **${totals.total}** | **${formatPercent(totalReady, totals.total)}** | - -Readiness here means the generated contract operation is not marked with: - -> ${stats.warning} - -Operations marked with that warning should not be migrated to blindly. Prefer fixing backend OpenAPI annotations first so the generated contract has accurate request and response types, then migrate callers endpoint by endpoint. - -The current heuristic marks an operation as not ready when a request body or success response that should have a body contains a loose object type, when a mutating controller reads a JSON body that is not documented as a request body, or when an operation has no documented 2xx response. 204, 205, and 304 responses are treated as bodyless when the request type is otherwise accurate. - -${readinessEndMarker} -` -} - -const updateReadme = (readinessSection) => { - const readme = fs.readFileSync(readmePath, 'utf8') - const startIndex = readme.indexOf(readinessStartMarker) - const endIndex = readme.indexOf(readinessEndMarker) - - if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { - throw new Error( - `Missing readiness markers in ${readmePath}. Expected ${readinessStartMarker} and ${readinessEndMarker}.`, - ) - } - - const nextReadme = [ - readme.slice(0, startIndex), - readinessSection, - readme.slice(endIndex + readinessEndMarker.length), - ].join('') - - fs.writeFileSync(readmePath, nextReadme) -} - -updateReadme(renderReadinessSection(collectStats()))