Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [ main ]

permissions:
contents: read

env:
PDP_API_KEY: test

Expand Down Expand Up @@ -37,4 +40,37 @@ jobs:
run: yarn build

- name: Run all tests
run: yarn test
run: yarn test

codegen-guard:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read

steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: 'yarn'

- name: Setup Java (required by openapi-generator-cli)
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '17'

- name: Install dependencies
run: yarn install --frozen-lockfile --ignore-scripts

- name: Test codegen guard failure paths
run: yarn test:codegen

- name: Codegen type-shape guard
run: yarn check:codegen
2 changes: 1 addition & 1 deletion openapitools.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "6.2.1"
"version": "7.25.0"
}
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@
"reset-hard": "git clean -dfx && git reset --hard && yarn",
"prepare": "npm run build && husky install",
"prepare-release": "run-s reset-hard test cov:check doc:html version doc:publish",
"generate-openapi-client": "openapi-generator-cli generate -i https://api.permit.io/v2/openapi.json -g typescript-axios -o src/openapi/ --additional-properties=useSingleRequestParameter=true,withSeparateModelsAndApi=true,apiPackage=api,modelPackage=types --skip-validate-spec && yarn fix:prettier"
"generate-openapi-client": "openapi-generator-cli generate -i https://api.permit.io/v2/openapi.json -g typescript-axios -o src/openapi/ --additional-properties=useSingleRequestParameter=true,withSeparateModelsAndApi=true,apiPackage=api,modelPackage=types --skip-validate-spec && yarn fix:prettier",
"check:codegen": "node scripts/check-codegen.mjs",
"test:codegen": "ava --verbose scripts/check-codegen.spec.mjs",
"lint:codegen": "eslint scripts --ext .mjs && prettier --check \"scripts/*.mjs\""
},
"engines": {
"node": ">=10"
Expand Down
6 changes: 6 additions & 0 deletions scripts/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"root": true,
"env": { "node": true, "es2021": true },
"parserOptions": { "ecmaVersion": 2021, "sourceType": "module" },
"extends": ["eslint:recommended"]
}
250 changes: 250 additions & 0 deletions scripts/check-codegen.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
#!/usr/bin/env node
/*
* Regenerate the pinned OpenAPI 3.1 fixture using the repository's generator and
* package-script options. Check completion, named properties, unexpected `any`,
* and representative 3.1 null unions. This requires Java; the AVA regression tests
* mock only the generator process and run without Java. See the fixture README.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const FIXTURE = 'src/tests/codegen/fixtures/openapi-3.1.0.json';
const WRAPPER = join(ROOT, 'node_modules/@openapitools/openapi-generator-cli/main.js');

// These individual properties are intentionally unconstrained in the pinned API spec.
// No entire model is exempt from the checks below.
const FREE_FORM = new Set([
'audit-log-model.ts:input',
'audit-log-model.ts:result',
'audit-log-model.ts:context',
'detailed-audit-log-model.ts:input',
'detailed-audit-log-model.ts:result',
'detailed-audit-log-model.ts:context',
'error-details.ts:additional_info',
'generic-engine-decision-log.ts:input',
'generic-engine-decision-log.ts:result',
'generic-engine-decision-log.ts:context',
'jsonpatch-action.ts:value',
'opaengine-decision-log.ts:input',
'opaengine-decision-log.ts:result',
'opalhttp-fetcher-config.ts:data',
'raw-data.ts:input',
'raw-data.ts:result',
'raw-data.ts:context',
'raw-data1.ts:input',
'raw-data1.ts:result',
'raw-data1.ts:context',
]);
const EXPECTED = {
'role-create.ts:key': 'string',
'role-create.ts:extends': 'Array<string>',
'resource-role-create.ts:extends': 'Array<string>',
'group-assign-user.ts:tenant': 'string',
'tenant-obj.ts:id': 'string',
'user-obj.ts:id': 'string',
'action-obj.ts:id': 'string',
'codegen-probe.ts:nullable_string': 'string|null',
'codegen-probe.ts:nullable_any_of': 'string|null',
'codegen-probe.ts:nullable_ref': 'CodegenProbeInner|null',
'codegen-probe.ts:nullable_array': 'Array<number>|null',
};

function readJson(path) {
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch (err) {
throw new Error(`Cannot read JSON at ${path}: ${err.message}. Restore or repair this file.`);
}
}

function generateOptions() {
const script = readJson(join(ROOT, 'package.json')).scripts?.['generate-openapi-client'];
const tokens = typeof script === 'string' ? script.split('&&')[0].trim().split(/\s+/) : [];
if (tokens.shift() !== 'openapi-generator-cli' || tokens.shift() !== 'generate') {
throw new Error(
'Expected generate-openapi-client to start with openapi-generator-cli generate.',
);
}
const values = new Map();
let skipValidation = false;
while (tokens.length) {
const flag = tokens.shift();
if (['-i', '-g', '-o'].includes(flag)) {
values.set(flag, tokens.shift());
} else if (flag.startsWith('--additional-properties=')) {
values.set('additional', flag.slice('--additional-properties='.length));
} else if (flag === '--skip-validate-spec') {
skipValidation = true;
} else {
throw new Error(
`Unsupported generate-openapi-client option ${flag}; update the guard parser.`,
);
}
}
const additional = values.get('additional');
if (values.get('-g') !== 'typescript-axios' || !additional) {
throw new Error(
'generate-openapi-client must specify -g typescript-axios and --additional-properties.',
);
}
// The wrapper joins its arguments in a shell. Accept only unquoted option tokens.
if (!/^[\w=,.-]+$/.test(additional)) {
throw new Error('Unsupported additional-properties syntax; update the guard parser.');
}
const modelPackage = additional.split(',').find((option) => option.startsWith('modelPackage='));
const modelDir = modelPackage?.slice('modelPackage='.length);
if (!modelDir || !/^[\w-]+$/.test(modelDir)) {
throw new Error('generate-openapi-client must set modelPackage to a single directory name.');
}
return {
modelDir,
args: [
'-g',
values.get('-g'),
`--additional-properties=${additional}`,
...(skipValidation ? ['--skip-validate-spec'] : []),
],
};
}

function assertComplete(out, pin, modelDir) {
const metadata = join(out, '.openapi-generator');
for (const name of ['VERSION', 'FILES']) {
if (!existsSync(join(metadata, name))) {
throw new Error(`Missing completion metadata ${name}; inspect the generator log and rerun.`);
}
}
const version = readFileSync(join(metadata, 'VERSION'), 'utf8').trim();
if (version !== pin) {
throw new Error(
`Generator ran ${version}, but openapitools.json pins ${pin}. Check the wrapper.`,
);
}
const manifest = readFileSync(join(metadata, 'FILES'), 'utf8')
.split(/\r?\n/)
.filter((file) => file.startsWith(`${modelDir}/`) && file.endsWith('.ts'))
.map((file) => file.slice(modelDir.length + 1));
if (!manifest.length) {
throw new Error(
'Completion manifest FILES lists no models; inspect the generator log and rerun.',
);
}
const typesDir = join(out, modelDir);
const files = existsSync(typesDir) ? readdirSync(typesDir).filter((f) => f.endsWith('.ts')) : [];
for (const file of manifest) {
if (!files.includes(file)) {
throw new Error(
`Incomplete generation: manifest model ${file} is missing; rerun the generator.`,
);
}
}
if (files.length !== new Set(manifest).size) {
throw new Error(
'Generated models differ from the completion manifest FILES; rerun the generator.',
);
}
return { typesDir, files };
}

function assertTypes(typesDir, files) {
const ts = createRequire(import.meta.url)('typescript');
function containsAny(node) {
if (!node) return false;
if (node.kind === ts.SyntaxKind.AnyKeyword) return true;
// A nested dictionary is legitimate free-form data, not a collapsed model.
if (ts.isIndexSignatureDeclaration(node)) return false;
return Boolean(ts.forEachChild(node, containsAny));
}

const properties = new Map();
const problems = [];
for (const file of files) {
const source = ts.createSourceFile(
file,
readFileSync(join(typesDir, file), 'utf8'),
ts.ScriptTarget.Latest,
true,
);
if (source.parseDiagnostics.length) {
throw new Error(`Invalid generated TypeScript in ${file}; inspect the generator output.`);
}
for (const declaration of source.statements) {
if (ts.isTypeAliasDeclaration(declaration) && containsAny(declaration.type)) {
problems.push(`${file}: unexpected any in type alias ${declaration.name.text}`);
}
if (!ts.isInterfaceDeclaration(declaration)) continue;
for (const member of declaration.members) {
if (ts.isIndexSignatureDeclaration(member) && containsAny(member.type)) {
problems.push(`${file}: unexpected top-level any index signature`);
}
if (!ts.isPropertySignature(member)) continue;
const key = `${file}:${member.name.text}`;
properties.set(key, member.type?.getText(source).replace(/\s+/g, ''));
if ((!member.type || containsAny(member.type)) && !FREE_FORM.has(key)) {
problems.push(`${key}: unexpected any (named type lost)`);
}
}
}
}
for (const [key, expected] of Object.entries(EXPECTED)) {
if (properties.get(key) !== expected) {
problems.push(`${key}: expected ${expected}, got ${properties.get(key) ?? 'missing'}`);
}
}
if (problems.length) {
throw new Error(
`${problems.length} type-shape regression(s):\n ${problems.slice(0, 12).join('\n ')}\n` +
'Inspect the fixture and models before changing the generator pin or expectations.',
);
}
}

let out;
let generator = '';
try {
if (!existsSync(join(ROOT, FIXTURE))) {
throw new Error(`Fixture spec not found: ${FIXTURE}; restore the committed fixture.`);
}
if (readJson(join(ROOT, FIXTURE)).openapi !== '3.1.0') {
throw new Error('The codegen fixture must declare OpenAPI 3.1.0; do not downgrade its header.');
}
if (!existsSync(WRAPPER)) {
throw new Error(`openapi-generator-cli not found at ${WRAPPER}; run yarn install first.`);
}
const pin = readJson(join(ROOT, 'openapitools.json'))['generator-cli']?.version;
if (typeof pin !== 'string' || !/^\d+\.\d+\.\d+$/.test(pin)) {
throw new Error('openapitools.json must pin an explicit stable generator version.');
}
generator = ` (generator ${pin})`;
const { args, modelDir } = generateOptions();
// Relative paths survive the wrapper's shell join even when ROOT or TMPDIR has spaces.
out = mkdtempSync(join(ROOT, 'node_modules/.codegen-'));
try {
execFileSync(
process.execPath,
[WRAPPER, 'generate', '-i', FIXTURE, '-o', relative(ROOT, out), ...args],
{ cwd: ROOT, env: { ...process.env, PWD: ROOT, INIT_CWD: ROOT }, stdio: 'inherit' },
);
} catch (err) {
const reason = err.signal ? `signal ${err.signal}` : `exit ${err.status ?? err.code}`;
throw new Error(
`openapi-generator-cli ${pin} aborted (${reason}). ` +
'Check Java 11+ on PATH, access to Maven Central, and the generator output above.',
);
}
const { typesDir, files } = assertComplete(out, pin, modelDir);
assertTypes(typesDir, files);
console.log(
`codegen guard OK - generator ${pin}; ${files.length} models checked for unexpected any; ` +
`${Object.keys(EXPECTED).length} property shapes verified`,
);
} catch (err) {
console.error(`codegen guard FAILED${generator}:\n${err.message}`);
process.exitCode = 1;
} finally {
if (out) rmSync(out, { recursive: true, force: true });
}
Loading
Loading