Skip to content

Instantly share code, notes, and snippets.

@azu
Last active February 9, 2021 23:06
Show Gist options
  • Save azu/5b15fdd5c62b14c2e36f6136d518e0ae to your computer and use it in GitHub Desktop.
Save azu/5b15fdd5c62b14c2e36f6136d518e0ae to your computer and use it in GitHub Desktop.
Generate JSON Schema Validator functions from TypeScript code-base → https://github.com/azu/create-ts-validator

Edit: Create https://github.com/azu/create-ts-validator


Purposes

Code generator: TypeScript → JSON Schema

  • Generate JSON Schema Validator functions from TypeScript code
  • TypeScript is a single source of truth

Structure

.
├── api-types-validator.ts
└── src/
    ├── hello/
    │   ├── api-types.ts
    │   ├── api-types.validator.ts
    │   └── index.ts
    └── status/
        ├── api-types.ts
        ├── api-types.validator.ts
        └── index.ts

Usage code generator

# 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

Usage of validator

import express, { Request, Response } from 'express';
import { GetAPIQuery, GetAPIResponseBody } from './api-types';
import { validateGetAPIQuery } from './api-types.validator';

const app = express.Router();
app.get(
  '/api',
  (
      req: Request<{}, {}, {}, GetAPIQuery>,
      res: Response<GetAPIResponseBody>,
      next,
  ) => {
      const { id } = validateGetAPIQuery(req.query);
      // ... your logics ...
      res.json({ ok: true });
  ),
);
export deafult app;

Related

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');
}),
);
}
})();
// Example api-types
// GET /api
export type GetAPIQuery = {
id: string;
};
export type GetAPIResponseBody = {
ok: boolean;
};
// @ts-nocheck
// DO NOT EDIT!!!!
import Ajv from 'ajv';
import * as apiTypes from './api-types';
const ajv = new Ajv();
export function validateGetAPIQuery(payload: unknown): apiTypes.GetAPIQuery {
if (!isGetAPIQuery(payload)) {
throw new Error('invalid payload: GetAPIQuery');
}
return payload;
}
export function isGetAPIQuery(payload: unknown): payload is apiTypes.GetAPIQuery {
const ajvValidate = ajv.compile({
type: 'object',
properties: {
id: {
type: 'string',
},
},
required: ['id'],
});
const isValid = ajvValidate(payload) as boolean;
if (!isValid) {
console.error('invalid payload: GetAPIQuery', {
errors: ajvValidate.errors,
data: JSON.stringify(payload),
});
return false;
}
return isValid;
}
export function validateGetAPIResponseBody(payload: unknown): apiTypes.GetAPIResponseBody {
if (!isGetAPIResponseBody(payload)) {
throw new Error('invalid payload: GetAPIResponseBody');
}
return payload;
}
export function isGetAPIResponseBody(payload: unknown): payload is apiTypes.GetAPIResponseBody {
const ajvValidate = ajv.compile({
type: 'object',
properties: {
ok: {
type: 'boolean',
},
},
required: ['ok'],
});
const isValid = ajvValidate(payload) as boolean;
if (!isValid) {
console.error('invalid payload: GetAPIResponseBody', {
errors: ajvValidate.errors,
data: JSON.stringify(payload),
});
return false;
}
return isValid;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment