|
import * as path from 'path'; |
|
import * as fs from 'fs/promises'; |
|
import * as TJS from 'typescript-json-schema'; |
|
import * as globby from 'globby'; |
|
import watch from 'glob-watcher'; |
|
import * as prettier from 'prettier'; |
|
import * as assert from 'assert'; |
|
/** |
|
* Generate from api-types.ts to api-types.validator.ts |
|
* api-types.validator.ts provide validation functions using JSON Schema. |
|
* |
|
* Usage: |
|
* |
|
* Generate |
|
* $ ts-node --transpile-only .api-types-validator.ts |
|
* Generate + Watch |
|
* $ ts-node --transpile-only ./api-types-validator.ts --watch |
|
* Test |
|
* $ ts-node --transpile-only ./api-types-validator.ts --check |
|
* |
|
**/ |
|
// |
|
const prettierCode = async (content: string, baseDir: string) => { |
|
return prettier.resolveConfig(baseDir).then(options => { |
|
return prettier.format(content, { |
|
parser: 'typescript', |
|
...options, |
|
}); |
|
}); |
|
}; |
|
|
|
const compilerOptions: TJS.CompilerOptions = { |
|
strictNullChecks: true, |
|
}; |
|
|
|
const generateValidatorCode = ({ |
|
apiTypesCode, |
|
schema, |
|
}: { |
|
apiTypesCode: string; |
|
schema: TJS.Definition; |
|
}) => { |
|
const isExportedTypeInApiTypes = (apiName: string) => { |
|
return ( |
|
apiTypesCode.includes(`export type ${apiName} =`) || |
|
apiTypesCode.includes(`export interface ${apiName} {`) |
|
); |
|
}; |
|
const banner = `// @ts-nocheck |
|
// DO NOT EDIT!!! |
|
import Ajv from 'ajv'; |
|
import * as apiTypes from './api-types'; |
|
const ajv = new Ajv(); |
|
`; |
|
const code = Object.entries(schema.definitions) |
|
.filter(([apiName]) => { |
|
return isExportedTypeInApiTypes(apiName); |
|
}) |
|
.map(([apiName, schema]) => { |
|
return `export function validate${apiName}(payload: unknown): apiTypes.${apiName} { |
|
if (!is${apiName}(payload)) { |
|
throw new Error('invalid payload: ${apiName}'); |
|
} |
|
return payload; |
|
} |
|
|
|
export function is${apiName}(payload: unknown): payload is apiTypes.${apiName} { |
|
const ajvValidate = ajv.compile(${JSON.stringify(schema, null, 4)}); |
|
const isValid = ajvValidate(payload) as boolean; |
|
if (!isValid) { |
|
console.error('invalid payload: ${apiName}', { |
|
errors: ajvValidate.errors, |
|
data: JSON.stringify(payload), |
|
}); |
|
return false; |
|
} |
|
return isValid; |
|
}`; |
|
}) |
|
.join('\n\n'); |
|
return `${banner} |
|
${code}`; |
|
}; |
|
// <projectroot>/src/**/api-types.ts |
|
// <projectroot>/api-types-validator.ts |
|
const watchRootDir = __dirname |
|
/** |
|
* target to generate api-types.validator.ts |
|
*/ |
|
const targetGlobs = [ |
|
'src/**/api-types.ts' |
|
]; |
|
|
|
async function generateValidator(filePath: string) { |
|
const absoluteFilePath = path.resolve(watchRootDir, filePath); |
|
const baseDir = path.dirname(absoluteFilePath); |
|
const fileName = path.basename(absoluteFilePath, '.ts'); |
|
const apiTypesCode = await fs.readFile(filePath, 'utf-8'); |
|
try { |
|
const program = TJS.getProgramFromFiles([absoluteFilePath], compilerOptions, baseDir); |
|
const schema = TJS.generateSchema(program, '*', { |
|
ignoreErrors: true, |
|
required: true, |
|
}); |
|
if (!schema) { |
|
console.warn('No schema: ' + filePath); |
|
return; |
|
} |
|
const validator = generateValidatorCode({ schema, apiTypesCode }); |
|
const formattedValidator = await prettierCode(validator, baseDir); |
|
return { |
|
validatorFilePath: path.join(baseDir, fileName + '.validator.ts'), |
|
code: formattedValidator, |
|
}; |
|
} catch (error) { |
|
console.error('Fail: ' + filePath, 'Error:' + error.message); |
|
} |
|
} |
|
|
|
(async function main() { |
|
// --watch and build |
|
if (process.argv.includes('--watch')) { |
|
const watcher = watch(targetGlobs, { |
|
ignoreInitial: true, |
|
}); |
|
watcher.on('change', async filePath => { |
|
const result = await generateValidator(filePath); |
|
if (!result) { |
|
return; |
|
} |
|
return fs.writeFile(result.validatorFilePath, result.code, 'utf-8'); |
|
}); |
|
} |
|
// --check: validate the difference current of source |
|
if (process.argv.includes('--check')) { |
|
const files = globby.sync(targetGlobs); |
|
await Promise.all( |
|
files.map(async filePath => { |
|
const result = await generateValidator(filePath); |
|
if (!result) { |
|
return; |
|
} |
|
try { |
|
await fs.access(result.validatorFilePath); |
|
} catch { |
|
return; |
|
} |
|
const oldValidatorCode = await fs.readFile(result.validatorFilePath, 'utf-8'); |
|
try { |
|
assert.strictEqual(oldValidatorCode, result.code); |
|
} catch (error) { |
|
console.error('Please re-generate validator:' + filePath); |
|
throw error; |
|
} |
|
console.log('OK: ' + filePath); |
|
}), |
|
); |
|
} else { |
|
const files = globby.sync(targetGlobs); |
|
await Promise.all( |
|
files.map(async filePath => { |
|
const result = await generateValidator(filePath); |
|
if (!result) { |
|
return; |
|
} |
|
console.log('Create:' + filePath); |
|
return fs.writeFile(result.validatorFilePath, result.code, 'utf-8'); |
|
}), |
|
); |
|
} |
|
})(); |