add api generator

This commit is contained in:
Vlad
2021-03-01 13:32:27 +03:00
parent 1453c27d87
commit 59a3045615
5 changed files with 261 additions and 124 deletions

View File

@@ -10,3 +10,4 @@ export const trimQuotes = (str: string) => {
}; };
export const GENERATOR_ENTITY_ALLIAS = 'Entities/'; export const GENERATOR_ENTITY_ALLIAS = 'Entities/';
export const BAD_REQUES_HELPER = 'BadRequesHelper';

View File

@@ -11,8 +11,8 @@ const generateApi = (openApi: OpenApi) => {
const ent = new EntitiesGenerator(openApi); const ent = new EntitiesGenerator(openApi);
ent.save(); ent.save();
// const api = new ApisGenerator(openApi); const api = new ApisGenerator(openApi);
// api.save(); api.save();
} }
const openApiFile = fs.readFileSync('./scripts/generator/v1.yaml', 'utf8'); const openApiFile = fs.readFileSync('./scripts/generator/v1.yaml', 'utf8');

View File

@@ -2,15 +2,16 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */ /* eslint-disable @typescript-eslint/no-unused-expressions */
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { stringify } from 'qs'; import { number } from 'prop-types';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import * as morph from 'ts-morph'; import * as morph from 'ts-morph';
import { import {
API_DIR as API_DIR_CONST, API_DIR as API_DIR_CONST,
BAD_REQUES_HELPER,
GENERATOR_ENTITY_ALLIAS, GENERATOR_ENTITY_ALLIAS,
} from '../../consts'; } from '../../consts';
import { toCamel, capitalize, schemaParamParser } from './utils'; import { toCamel, capitalize, schemaParamParser, OpenApi, uncapitalize, RequestBody } from './utils';
const API_DIR = path.resolve(API_DIR_CONST); const API_DIR = path.resolve(API_DIR_CONST);
@@ -20,11 +21,15 @@ if (!fs.existsSync(API_DIR)) {
const { Project, QuoteKind } = morph; const { Project, QuoteKind } = morph;
enum PROCESS_AS {
JSON = 'JSON',
TEXT = 'TEXT',
EMPTY = 'EMPTY',
}
class ApiGenerator { class ApiGenerator {
project = new Project({ project = new Project({
tsConfigFilePath: './tsconfig.json', tsConfigFilePath: './tsconfig.json',
addFilesFromTsConfig: false,
manipulationSettings: { manipulationSettings: {
quoteKind: QuoteKind.Single, quoteKind: QuoteKind.Single,
usePrefixAndSuffixTextForRename: false, usePrefixAndSuffixTextForRename: false,
@@ -32,7 +37,7 @@ class ApiGenerator {
}, },
}); });
openapi: Record<string, any>; openapi: OpenApi;
serverUrl: string; serverUrl: string;
@@ -47,7 +52,7 @@ class ApiGenerator {
apis: morph.SourceFile[] = []; apis: morph.SourceFile[] = [];
constructor(openapi: Record<string, any>) { constructor(openapi: OpenApi) {
this.openapi = openapi; this.openapi = openapi;
this.paths = openapi.paths; this.paths = openapi.paths;
this.serverUrl = openapi.servers[0].url; this.serverUrl = openapi.servers[0].url;
@@ -55,12 +60,14 @@ class ApiGenerator {
Object.keys(this.paths).forEach((pathKey) => { Object.keys(this.paths).forEach((pathKey) => {
Object.keys(this.paths[pathKey]).forEach((method) => { Object.keys(this.paths[pathKey]).forEach((method) => {
const { const {
tags, operationId, parameters, responses, requestBody, security, tags, operationId, parameters, responses, requestBody, security, "x-skip-web-api": skip
} = this.paths[pathKey][method]; } = this.paths[pathKey][method];
const controller = toCamel((tags ? tags[0] : pathKey.split('/')[1]).replace('-controller', '')); const controller = toCamel((tags ? tags[0] : pathKey.split('/')[1]));
if (skip) {
return;
}
if (this.controllers[controller]) { if (this.controllers[controller]) {
this.controllers[controller][operationId] = { this.controllers[controller][uncapitalize(operationId)] = {
parameters, parameters,
responses, responses,
method, method,
@@ -69,7 +76,7 @@ class ApiGenerator {
pathKey: pathKey.replace(/{/g, '${'), pathKey: pathKey.replace(/{/g, '${'),
}; };
} else { } else {
this.controllers[controller] = { [operationId]: { this.controllers[controller] = { [uncapitalize(operationId)]: {
parameters, parameters,
responses, responses,
method, method,
@@ -97,7 +104,7 @@ class ApiGenerator {
]); ]);
// const schemaProperties = schemas[schemaName].properties; // const schemaProperties = schemas[schemaName].properties;
const importEntities: any[] = []; const importEntities: { type: string, isClass: boolean }[] = [];
// add api class to file // add api class to file
const apiClass = apiFile.addClass({ const apiClass = apiFile.addClass({
@@ -111,29 +118,34 @@ class ApiGenerator {
// for each operation add fetcher // for each operation add fetcher
operationList.forEach((operation) => { operationList.forEach((operation) => {
const { const {
requestBody, responses, parameters, method, pathKey, security, requestBody, responses, parameters, method, pathKey,
} = controllerOperations[operation]; } = controllerOperations[operation];
const queryParams: any[] = []; // { name, type } const queryParams: { name: string, type: string, hasQuestionToken: boolean }[] = [];
const bodyParam: any[] = []; // { name, type } const bodyParam: { name: string, countedType: string, type?: string, isClass?: boolean, hasQuestionToken: boolean }[] = [];
let contentType: string = '';
let hasResponseBodyType: /* boolean | ReturnType<schemaParamParser> */ false | [string, boolean, boolean, boolean, boolean] = false;
let contentType = '';
if (parameters) { if (parameters) {
parameters.forEach((p: any) => { parameters.forEach((link: {$ref: string}) => {
const [ const temp = link.$ref.split('/').pop()
pType, isArray, isClass, isImport, const parameter = this.openapi.components.parameters[temp!];
] = schemaParamParser(p.schema, this.openapi);
const {
type, isArray, isClass, isImport,
} = schemaParamParser(parameter.schema, this.openapi);
if (isImport) { if (isImport) {
importEntities.push({ type: pType, isClass }); importEntities.push({ type, isClass });
} }
if (p.in === 'query') { if (parameter.in === 'query') {
queryParams.push({ queryParams.push({
name: p.name, type: `${pType}${isArray ? '[]' : ''}`, hasQuestionToken: !p.required }); name: parameter.name, type: `${type}${isArray ? '[]' : ''}`, hasQuestionToken: !parameter.required });
} }
}); });
} }
if (queryParams.length > 0) { if (queryParams.length > 0) {
const imp = apiFile.getImportDeclaration((i) => { const imp = apiFile.getImportDeclaration((i) => {
return i.getModuleSpecifierValue() === 'qs'; return i.getModuleSpecifierValue() === 'qs';
@@ -144,62 +156,120 @@ class ApiGenerator {
}); });
} }
} }
if (requestBody) { if (requestBody) {
let content = requestBody.content;
const { $ref }: { $ref: string } = requestBody; const { $ref }: { $ref: string } = requestBody;
if (!content && $ref) { const name = $ref.split('/').pop();
const name = $ref.split('/').pop() as string; const { content, required } = this.openapi.components.requestBodies[name!];
content = this.openapi.components.requestBodies[name].content;
}
[contentType] = Object.keys(content); [contentType] = Object.keys(content);
const data = content[contentType]; const data = content[contentType as keyof RequestBody['content']]!;
const [ const {
pType, isArray, isClass, isImport, type, isArray, isClass, isImport,
] = schemaParamParser(data.schema, this.openapi); } = schemaParamParser(data.schema, this.openapi);
if (isImport) { if (isImport) {
importEntities.push({ type: pType, isClass }); importEntities.push({ type: type, isClass });
bodyParam.push({ name: pType.toLowerCase(), type: `${isClass ? 'I' : ''}${pType}${isArray ? '[]' : ''}`, isClass, pType }); bodyParam.push({
name: type.toLowerCase(),
countedType: `${isClass ? 'I' : ''}${type}${isArray ? '[]' : ''}`,
isClass,
type,
hasQuestionToken: !required
});
} else { } else {
bodyParam.push({ name: 'data', type: `${pType}${isArray ? '[]' : ''}` }); bodyParam.push({
name: 'data',
countedType: `${type}${isArray ? '[]' : ''}`,
hasQuestionToken: !required });
} }
} }
if (responses['200']) {
const { content, headers } = responses['200'];
if (content && (content['*/*'] || content['application/json'])) {
const { schema, examples } = content['*/*'] || content['application/json'];
if (!schema) { const responsesCodes = Object.keys(responses);
process.exit(0); const responsesSchema = responsesCodes.map((code) => {
const refLink = responses[code].$ref.split('/').pop();
const ref = this.openapi.components.responses[refLink];
interface ResponseSchema {
code: number,
[PROCESS_AS.JSON]?: ReturnType<typeof schemaParamParser>;
[PROCESS_AS.TEXT]?: {
schema?: ReturnType<typeof schemaParamParser>;
xErrorCode?: string;
onlyText: boolean;
} }
[PROCESS_AS.EMPTY]?: boolean;
}
const responseSchema: ResponseSchema = { code: Number(code) };
const propType = schemaParamParser(schema, this.openapi); if (!ref.content) {
const [pType, , isClass, isImport] = propType; responseSchema[PROCESS_AS.EMPTY] = true;
return responseSchema;
}
if (ref.content?.['application/json']) {
const { schema } = ref.content['application/json'];
responseSchema[PROCESS_AS.JSON] = schemaParamParser(schema, this.openapi);
}
if (ref.content?.['text/palin']) {
const {
"x-error-class": xErrorClass,
"x-error-code": xErrorCode,
} = ref.content['text/palin'];
if (xErrorClass) {
const schemaLink = xErrorClass.split('/').pop();
const schema = this.openapi.components.schemas[schemaLink!];
responseSchema[PROCESS_AS.TEXT] = {
schema: schemaParamParser(schema, this.openapi),
xErrorCode,
onlyText: false,
}
} else {
responseSchema[PROCESS_AS.TEXT] = { onlyText: true };
}
}
return responseSchema;
});
let returnTypes = new Set();
bodyParam.forEach((param) => {
if (param.isClass) {
returnTypes.add(BAD_REQUES_HELPER);
importEntities.push({ type: BAD_REQUES_HELPER, isClass: true });
}
})
responsesSchema.forEach((responseSchema) => {
if (responseSchema[PROCESS_AS.JSON]) {
const { type, isClass, isImport } = responseSchema[PROCESS_AS.JSON]!;
returnTypes.add(type);
if (isImport) { if (isImport) {
importEntities.push({ type: pType, isClass }); importEntities.push({ type: type, isClass });
} }
hasResponseBodyType = propType;
} }
} if (responseSchema[PROCESS_AS.TEXT]) {
let returnType = ''; const { onlyText, schema } = responseSchema[PROCESS_AS.TEXT]!;
if (hasResponseBodyType) { if (onlyText) {
const [pType, isArray, isClass] = hasResponseBodyType as any; returnTypes.add('string');
let data = `Promise<${isClass ? 'I' : ''}${pType}${isArray ? '[]' : ''}`; } else {
returnType = data; const { type, isClass, isImport } = schema!;
} else { returnTypes.add(type);
returnType = 'Promise<number'; if (isImport) {
} importEntities.push({ type, isClass });
const shouldValidate = bodyParam.filter(b => b.isClass); }
if (shouldValidate.length > 0) { }
returnType += ' | string[]'; }
} if (responseSchema[PROCESS_AS.EMPTY]) {
// append Error to default type return; returnTypes.add('number');
returnType += ' | Error>'; }
});
returnTypes.add('undefined');
const returnType = `Promise<${Array.from(returnTypes).join(' | ')}>`;
const fetcher = apiClass.addMethod({ const fetcher = apiClass.addMethod({
isAsync: true, isAsync: true,
@@ -211,23 +281,19 @@ class ApiGenerator {
fetcher.addParameters(params); fetcher.addParameters(params);
fetcher.setBodyText((w) => { fetcher.setBodyText((w) => {
// Add data to URLSearchParams if (contentType === 'application/json') {
if (contentType === 'text/plain') { const shouldValidate = bodyParam.filter(b => b.isClass);
bodyParam.forEach((b) => {
w.writeLine(`const params = String(${b.name});`);
});
} else {
if (shouldValidate.length > 0) { if (shouldValidate.length > 0) {
w.writeLine(`const haveError: string[] = [];`); w.writeLine(`const haveError: string[] = [];`);
shouldValidate.forEach((b) => { shouldValidate.forEach((b) => {
w.writeLine(`const ${b.name}Valid = new ${b.pType}(${b.name});`); w.writeLine(`haveError.push(...${b.name}.validate());`);
w.writeLine(`haveError.push(...${b.name}Valid.validate());`);
}); });
w.writeLine(`if (haveError.length > 0) {`); w.writeLine(`if (haveError.length > 0) {`);
w.writeLine(` return Promise.resolve(haveError);`) w.writeLine(` return Promise.resolve(new ${BAD_REQUES_HELPER}(haveError));`)
w.writeLine(`}`); w.writeLine(`}`);
} }
} }
// Switch return of fetch in case on queryParams // Switch return of fetch in case on queryParams
if (queryParams.length > 0) { if (queryParams.length > 0) {
w.writeLine('const queryParams = {'); w.writeLine('const queryParams = {');
@@ -243,37 +309,36 @@ class ApiGenerator {
w.writeLine(` method: '${method.toUpperCase()}',`); w.writeLine(` method: '${method.toUpperCase()}',`);
// add Fetch options // add Fetch options
if (contentType && contentType !== 'multipart/form-data') {
w.writeLine(' headers: {');
w.writeLine(` 'Content-Type': '${contentType}',`);
w.writeLine(' },');
}
if (contentType) { if (contentType) {
switch (contentType) { w.writeLine(` body: JSON.stringify(${bodyParam.map((b) => b.isClass ? `${b.name}.serialize()` : b.name).join(', ')}),`);
case 'text/plain': }
w.writeLine(' body: params,');
break; w.writeLine('}).then(async (res) => {');
default: responsesSchema.forEach((responseSchema) => {
w.writeLine(` body: JSON.stringify(${bodyParam.map((b) => b.isClass ? `${b.name}Valid.serialize()` : b.name).join(', ')}),`); const { code } = responseSchema;
break; w.writeLine(` if (res.status === ${code}) {`);
if (responseSchema[PROCESS_AS.EMPTY]) {
w.writeLine(' return res.status;');
} }
} if (responseSchema[PROCESS_AS.TEXT]?.onlyText) {
w.writeLine(' return res.text();')
// Handle response }
if (hasResponseBodyType) { if (responseSchema[PROCESS_AS.JSON] && responseSchema[PROCESS_AS.TEXT]) {
w.writeLine('}).then(async (res) => {'); const { type } = responseSchema[PROCESS_AS.JSON]!;
w.writeLine(' if (res.status === 200) {'); const { schema, xErrorCode } = responseSchema[PROCESS_AS.TEXT]!;
w.writeLine(' return res.json();'); const { type: errType } = schema!;
} else { w.writeLine(' try {');
w.writeLine('}).then(async (res) => {'); w.writeLine(` return new ${type}(await res.json());`);
w.writeLine(' if (res.status === 200) {'); w.writeLine(' } catch {');
w.writeLine(' return res.status;'); w.writeLine(` return new ${errType}({ msg: await res.text() code: ${xErrorCode}} as any);`);
} w.writeLine(' }');
}
// Handle Error if (responseSchema[PROCESS_AS.JSON]) {
w.writeLine(' } else {'); const { type } = responseSchema[PROCESS_AS.JSON]!;
w.writeLine(' return new Error(String(res.status));'); w.writeLine(` return new ${type}(await res.json());`);
w.writeLine(' }'); }
w.writeLine(` }`);
})
w.writeLine('})'); w.writeLine('})');
}); });
}); });
@@ -288,17 +353,16 @@ class ApiGenerator {
} }
}); });
imports.sort((a,b) => a.type > b.type ? 1 : -1).forEach((ie) => { imports.sort((a,b) => a.type > b.type ? 1 : -1).forEach((ie) => {
const { type: pType, isClass } = ie; const { type: type, isClass } = ie;
if (isClass) { if (isClass) {
apiFile.addImportDeclaration({ apiFile.addImportDeclaration({
moduleSpecifier: `${GENERATOR_ENTITY_ALLIAS}${pType}`, moduleSpecifier: `${GENERATOR_ENTITY_ALLIAS}${type}`,
defaultImport: pType, defaultImport: type,
namedImports: [`I${pType}`],
}); });
} else { } else {
apiFile.addImportDeclaration({ apiFile.addImportDeclaration({
moduleSpecifier: `${GENERATOR_ENTITY_ALLIAS}${pType}`, moduleSpecifier: `${GENERATOR_ENTITY_ALLIAS}${type}`,
namedImports: [pType], namedImports: [type],
}); });
} }
}); });

View File

@@ -3,7 +3,7 @@ import * as path from 'path';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import * as morph from 'ts-morph'; import * as morph from 'ts-morph';
import { ENT_DIR } from '../../consts'; import { ENT_DIR, BAD_REQUES_HELPER } from '../../consts';
import { TYPES, toCamel, schemaParamParser, capitalize, OpenApi, Schema } from './utils'; import { TYPES, toCamel, schemaParamParser, capitalize, OpenApi, Schema } from './utils';
const { Project, QuoteKind } = morph; const { Project, QuoteKind } = morph;
@@ -37,6 +37,40 @@ class EntitiesGenerator {
this.schemas = openapi.components.schemas; this.schemas = openapi.components.schemas;
this.schemaNames = Object.keys(this.schemas); this.schemaNames = Object.keys(this.schemas);
this.generateEntities(); this.generateEntities();
this.generateUtils();
}
generateUtils = () => {
const helperFile = this.project.createSourceFile(`${EntDir}/${BAD_REQUES_HELPER}.ts`);
helperFile.addImportDeclaration({
moduleSpecifier: `./BadRequestResp`,
defaultImport: 'BadRequestResp',
});
helperFile.addImportDeclaration({
moduleSpecifier: `./ErrorCode`,
namedImports: ['ErrorCode'],
});
const helperClass = helperFile.addClass({
name: 'BadRequestHelper',
isDefaultExport: true,
extends: 'BadRequestResp',
properties: [{
type: 'string[]',
name: 'fields'
}]
});
const helperConstructor = helperClass.addConstructor({
parameters: [{
type: 'string[]',
name: 'fields'
}],
});
helperConstructor.setBodyText((w) => {
w.writeLine('super({ code: ErrorCode.JSN001, msg: \'Wrong fields value\' });');
w.writeLine('this.fields = fields;')
});
this.entities.push(helperFile);
} }
generateEntities = () => { generateEntities = () => {
@@ -199,7 +233,7 @@ class EntitiesGenerator {
'', '',
]); ]);
let { properties, required, allOf } = this.schemas[schemaName]; let { properties, required, allOf, $ref } = this.schemas[schemaName];
if (allOf) { if (allOf) {
const refLink: string = allOf.find((obj: Record<string, any>) => obj.$ref).$ref; const refLink: string = allOf.find((obj: Record<string, any>) => obj.$ref).$ref;
@@ -213,6 +247,38 @@ class EntitiesGenerator {
required = newSchema.required; required = newSchema.required;
} }
if ($ref) {
const refLink = $ref.split('/').pop()!;
entityFile.addImportDeclaration({
defaultImport: refLink,
moduleSpecifier: `./${refLink}`,
namedImports: [`I${refLink}`],
});
entityFile.addTypeAlias({
name: `I${schemaName}`,
type: `I${refLink}`,
isExported: true,
})
const entityClass = entityFile.addClass({
name: schemaName,
isDefaultExport: true,
extends: refLink,
})
const ctor = entityClass.addConstructor({
parameters: [{
name: 'props',
type: `I${schemaName}`,
}],
})
ctor.setBodyText((w) => {
w.writeLine('super(props);')
});
this.entities.push(entityFile);
return;
}
const entityInterface = entityFile.addInterface({ const entityInterface = entityFile.addInterface({
name: `I${schemaName}`, name: `I${schemaName}`,

View File

@@ -49,12 +49,12 @@ export interface Schema {
minimum?: number; minimum?: number;
} }
export interface Parametr { export interface Parameter {
description?: string; description?: string;
example?: string; example?: string;
in?: 'query' | 'body' | 'headers'; in?: 'query' | 'body' | 'headers';
name?: string; name: string;
schema?: Schema; schema: Schema;
required?: boolean; required?: boolean;
} }
@@ -64,9 +64,6 @@ export interface RequestBody {
schema: Schema; schema: Schema;
example?: string; example?: string;
}; };
'text/palin'?: {
example?: string;
}
} }
required?: boolean; required?: boolean;
} }
@@ -78,13 +75,15 @@ export interface Response {
}; };
'text/palin'?: { 'text/palin'?: {
example?: string; example?: string;
'x-error-class'?: string;
'x-error-code'?: string;
} }
} }
description?: string; description?: string;
} }
export interface Schemas { export interface Schemas {
parameters: Record<string, Parametr>; parameters: Record<string, Parameter>;
requestBodies: Record<string, RequestBody>; requestBodies: Record<string, RequestBody>;
responses: Record<string, Response>; responses: Record<string, Response>;
schemas: Record<string, Schema>; schemas: Record<string, Schema>;
@@ -93,6 +92,10 @@ export interface Schemas {
export interface OpenApi { export interface OpenApi {
components: Schemas; components: Schemas;
paths: any; paths: any;
servers: {
description: string;
url: string;
}[]
} }
/** /**
@@ -118,16 +121,19 @@ const schemaParamParser = (schemaProp: Schema, openApi: OpenApi): SchemaParamPar
let isEnum = false; let isEnum = false;
if (schemaProp.$ref || schemaProp.additionalProperties?.$ref) { if (schemaProp.$ref || schemaProp.additionalProperties?.$ref) {
const temp = (schemaProp.$ref || schemaProp.additionalProperties?.$ref)!.split('/'); type = (schemaProp.$ref || schemaProp.additionalProperties?.$ref)!.split('/').pop()!;
if (schemaProp.additionalProperties) { if (schemaProp.additionalProperties) {
isAdditional = true; isAdditional = true;
} }
type = `${temp[temp.length - 1]}`;
const cl = openApi.components.schemas[type]; const cl = openApi.components.schemas[type];
if (cl.allOf) {
const ref = cl.allOf.find((e) => !!e.$ref);
const link = schemaParamParser(ref, openApi);
return {...link, type};
}
if (cl.$ref) { if (cl.$ref) {
const link = schemaParamParser(cl, openApi); const link = schemaParamParser(cl, openApi);
return {...link, type}; return {...link, type};