yapi-ts-builder
Version:
基于 yapi-to-typescript 实现的 YApi 接口定义生成工具
603 lines (602 loc) • 23 kB
JavaScript
import * as changeCase from 'change-case';
import fs from 'fs-extra';
import { compile } from 'json-schema-to-typescript';
import JSON5 from 'json5';
import nodeFetch from 'node-fetch';
import path from 'path';
import prettier from 'prettier';
import ProxyAgent from 'proxy-agent';
import toJsonSchema from 'to-json-schema';
import { URL } from 'url';
import { castArray, cloneDeepFast, forOwn, isArray, isEmpty, isObject, mapKeys, memoize, run, traverse, } from 'vtils';
import { FileData } from './helpers';
import { Method, RequestBodyType, RequestFormItemType, Required, ResponseBodyType, } from './types';
/**
* 抛出错误。
*
* @param msg 错误信息
*/
export function throwError(...msg) {
/* istanbul ignore next */
throw new Error(msg.join(''));
}
/**
* 原地遍历 JSONSchema。
*/
export function traverseJsonSchema(jsonSchema, cb, currentPath = []) {
/* istanbul ignore if */
if (!isObject(jsonSchema))
return jsonSchema;
// Mock.toJSONSchema 产生的 properties 为数组,然而 JSONSchema4 的 properties 为对象
if (isArray(jsonSchema.properties)) {
jsonSchema.properties = jsonSchema.properties.reduce((props, js) => {
props[js.name] = js;
return props;
}, {});
}
// 处理传入的 JSONSchema
cb(jsonSchema, currentPath);
// 继续处理对象的子元素
if (jsonSchema.properties) {
forOwn(jsonSchema.properties, (item, key) => traverseJsonSchema(item, cb, [...currentPath, key]));
}
// 继续处理数组的子元素
if (jsonSchema.items) {
castArray(jsonSchema.items).forEach((item, index) => traverseJsonSchema(item, cb, [...currentPath, index]));
}
// 处理 oneOf
if (jsonSchema.oneOf) {
jsonSchema.oneOf.forEach(item => traverseJsonSchema(item, cb, currentPath));
}
// 处理 anyOf
if (jsonSchema.anyOf) {
jsonSchema.anyOf.forEach(item => traverseJsonSchema(item, cb, currentPath));
}
// 处理 allOf
if (jsonSchema.allOf) {
jsonSchema.allOf.forEach(item => traverseJsonSchema(item, cb, currentPath));
}
return jsonSchema;
}
/**
* 原地处理 JSONSchema。
*
* @param jsonSchema 待处理的 JSONSchema
* @returns 处理后的 JSONSchema
*/
export function processJsonSchema(jsonSchema, customTypeMapping) {
return traverseJsonSchema(jsonSchema, jsonSchema => {
// 数组只取第一个判断类型
if (jsonSchema.type === 'array' &&
Array.isArray(jsonSchema.items) &&
jsonSchema.items.length) {
jsonSchema.items = jsonSchema.items[0];
}
// 处理类型名称为标准的 JSONSchema 类型名称
if (jsonSchema.type) {
// 类型映射表,键都为小写
const typeMapping = {
byte: 'integer',
short: 'integer',
int: 'integer',
long: 'integer',
float: 'number',
double: 'number',
bigdecimal: 'number',
char: 'string',
void: 'null',
...mapKeys(customTypeMapping, (_, key) => key.toLowerCase()),
};
const isMultiple = Array.isArray(jsonSchema.type);
const types = castArray(jsonSchema.type).map(type => {
// 所有类型转成小写,如:String -> string
type = type.toLowerCase();
// 映射为标准的 JSONSchema 类型
type = typeMapping[type] || type;
return type;
});
jsonSchema.type = isMultiple ? types : types[0];
}
// 移除字段名称首尾空格
if (jsonSchema.properties) {
forOwn(jsonSchema.properties, (_, prop) => {
const propDef = jsonSchema.properties[prop];
delete jsonSchema.properties[prop];
jsonSchema.properties[prop.trim()] = propDef;
});
if (Array.isArray(jsonSchema.required)) {
jsonSchema.required = jsonSchema.required.map(prop => prop.trim());
}
}
return jsonSchema;
});
}
/**
* 将路径统一为 unix 风格的路径。
*
* @param path 路径
* @returns unix 风格的路径
*/
export function toUnixPath(path) {
return path.replace(/[/\\]+/g, '/');
}
/**
* 获取适用于 JSTT 的 JSONSchema。
*
* @param jsonSchema 待处理的 JSONSchema
* @returns 适用于 JSTT 的 JSONSchema
*/
export function jsonSchemaToJSTTJsonSchema(jsonSchema, typeName) {
if (jsonSchema) {
// 去除最外层的 description 以防止 JSTT 提取它作为类型的注释
delete jsonSchema.description;
}
return traverseJsonSchema(jsonSchema, (jsonSchema, currentPath) => {
// 支持类型引用
const refValue =
// YApi 低版本不支持配置 title,可以在 description 里配置
jsonSchema.title == null ? jsonSchema.description : jsonSchema.title;
if (refValue === null || refValue === void 0 ? void 0 : refValue.startsWith('&')) {
const typeRelativePath = refValue.substring(1);
const typeAbsolutePath = toUnixPath(path
.resolve(path.dirname(`/${currentPath.join('/')}`.replace(/\/{2,}/g, '/')), typeRelativePath)
.replace(/^[a-z]+:/i, ''));
const typeAbsolutePathArr = typeAbsolutePath.split('/').filter(Boolean);
let tsTypeLeft = '';
let tsTypeRight = typeName;
for (const key of typeAbsolutePathArr) {
tsTypeLeft += 'NonNullable<';
tsTypeRight += `[${JSON.stringify(key)}]>`;
}
const tsType = `${tsTypeLeft}${tsTypeRight}`;
// 自定义的 TypeScript 类型表达式,解决标准 JSON Schema 无法表达复杂类型的限制
jsonSchema.tsType = tsType;
}
// 去除 title 和 id,防止 json-schema-to-typescript 提取它们作为接口名
delete jsonSchema.title;
delete jsonSchema.id;
// 忽略数组长度限制
delete jsonSchema.minItems;
delete jsonSchema.maxItems;
if (jsonSchema.type === 'object') {
// 将 additionalProperties 设为 false
jsonSchema.additionalProperties = false;
}
// 删除 default,防止 json-schema-to-typescript 根据它推测类型
delete jsonSchema.default;
return jsonSchema;
});
}
/**
* 将 JSONSchema 字符串转为 JSONSchema 对象。
*
* @param str 要转换的 JSONSchema 字符串
* @returns 转换后的 JSONSchema 对象
*/
export function jsonSchemaStringToJsonSchema(str, customTypeMapping) {
return processJsonSchema(JSON.parse(str), customTypeMapping);
}
/**
* 获得 JSON 数据的 JSONSchema 对象。
*
* @param json JSON 数据
* @returns JSONSchema 对象
*/
export function jsonToJsonSchema(json, customTypeMapping) {
const schema = toJsonSchema(json, {
required: false,
arrays: {
mode: 'first',
},
objects: {
additionalProperties: false,
},
strings: {
detectFormat: false,
},
postProcessFnc: (type, schema, value) => {
if (!schema.description && !!value && type !== 'object') {
schema.description = JSON.stringify(value);
}
return schema;
},
});
delete schema.description;
return processJsonSchema(schema, customTypeMapping);
}
/**
* 获得 mockjs 模板的 JSONSchema 对象。
*
* @param template mockjs 模板
* @returns JSONSchema 对象
*/
export function mockjsTemplateToJsonSchema(template, customTypeMapping) {
const actions = [];
// https://github.com/nuysoft/Mock/blob/refactoring/src/mock/constant.js#L27
const keyRe = /(.+)\|(?:\+(\d+)|([+-]?\d+-?[+-]?\d*)?(?:\.(\d+-?\d*))?)/;
// https://github.com/nuysoft/Mock/wiki/Mock.Random
const numberPatterns = [
'natural',
'integer',
'float',
'range',
'increment',
];
const boolPatterns = ['boolean', 'bool'];
const normalizeValue = (value) => {
if (typeof value === 'string' && value.startsWith('@')) {
const pattern = value.slice(1);
if (numberPatterns.some(p => pattern.startsWith(p))) {
return 1;
}
if (boolPatterns.some(p => pattern.startsWith(p))) {
return true;
}
}
return value;
};
traverse(template, (value, key, parent) => {
if (typeof key === 'string') {
actions.push(() => {
delete parent[key];
parent[
// https://github.com/nuysoft/Mock/blob/refactoring/src/mock/schema/schema.js#L16
key.replace(keyRe, '$1')] = normalizeValue(value);
});
}
});
actions.forEach(action => action());
return jsonToJsonSchema(template, customTypeMapping);
}
/**
* 获得属性定义列表的 JSONSchema 对象。
*
* @param propDefinitions 属性定义列表
* @returns JSONSchema 对象
*/
export function propDefinitionsToJsonSchema(propDefinitions, customTypeMapping) {
return processJsonSchema({
type: 'object',
required: propDefinitions.reduce((res, prop) => {
if (prop.required) {
res.push(prop.name);
}
return res;
}, []),
properties: propDefinitions.reduce((res, prop) => {
res[prop.name] = {
type: prop.type,
description: prop.comment,
...(prop.type === 'file' ? { tsType: FileData.name } : {}),
};
return res;
}, {}),
}, customTypeMapping);
}
const JSTTOptions = {
bannerComment: '',
enableConstEnums: false,
style: {
bracketSpacing: false,
printWidth: 120,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'none',
useTabs: false,
},
};
/**
* 解析枚举描述
* @param enumValues 枚举值
* @param enumDesc 枚举描述
* @returns 枚举值与描述的映射
*/
function parseEnumDescriptions(enumValues, enumDesc) {
const tsEnumNames = [];
const enumDescriptions = [];
if (enumDesc) {
// 分隔符
const separator = enumDesc.includes(';') ? ';' : '\n';
const lines = enumDesc.split(separator);
for (const line of lines) {
const [name, comment] = line.split(':').map(s => s.trim());
if (name) {
tsEnumNames.push(name);
}
if (comment) {
enumDescriptions.push(comment);
}
}
}
return {
tsEnumNames,
enumDescriptions,
};
}
// 【关键改动】处理 JSONSchema 的嵌套类型(递归提取所有嵌套结构为独立的接口类型)
function extractNestedTypes(schema, parentName, definitions = {}, usedTypeNames = {}) {
var _a;
// 处理当前 schema 的 properties
if (schema.type === 'object' && schema.properties) {
const newProperties = {};
for (const [key, value] of Object.entries(schema.properties)) {
if (value &&
typeof value === 'object' &&
value.enum &&
value.enumDesc &&
value.enumDesc.includes('枚举生成格式')) {
const pascalCaseKey = changeCase.pascalCase(key);
// 处理枚举类型
const enumName = usedTypeNames[pascalCaseKey]
? `${parentName}_${pascalCaseKey}`
: pascalCaseKey;
usedTypeNames[enumName] = true;
const { tsEnumNames, enumDescriptions } = parseEnumDescriptions(value.enum, (_a = value.enumDesc) === null || _a === void 0 ? void 0 : _a.slice(6));
// 生成枚举定义
definitions[enumName] = {
type: value === null || value === void 0 ? void 0 : value.type,
enum: value.enum,
description: value.description,
tsEnumNames: tsEnumNames, // 添加枚举别名
};
// 在当前 schema 中引用枚举类型
newProperties[key] = { $ref: `#/definitions/${enumName}` };
}
else if (value &&
typeof value === 'object' &&
value.type === 'object') {
// 为嵌套对象生成独立的接口名称(如果存在重复,则拼接父级名称)
const pascalCaseKey = changeCase.pascalCase(key);
const nestedInterfaceName = usedTypeNames[pascalCaseKey]
? `${parentName}_${pascalCaseKey}`
: pascalCaseKey;
usedTypeNames[nestedInterfaceName] = true;
// 递归提取嵌套结构
definitions[nestedInterfaceName] = extractNestedTypes(value, nestedInterfaceName, definitions, usedTypeNames);
// 在当前 schema 中引用嵌套接口
newProperties[key] = { $ref: `#/definitions/${nestedInterfaceName}` };
}
else if (value && typeof value === 'object' && value.type === 'array') {
// 处理数组类型的嵌套对象
const arrayItem = value.items;
if (arrayItem &&
typeof arrayItem === 'object' &&
arrayItem.type === 'object') {
const pascalCaseKey = `${changeCase.pascalCase(key)}_Item`;
const nestedInterfaceName = usedTypeNames[pascalCaseKey]
? `${parentName}_${pascalCaseKey}`
: pascalCaseKey;
usedTypeNames[nestedInterfaceName] = true;
definitions[nestedInterfaceName] = extractNestedTypes(arrayItem, nestedInterfaceName, definitions, usedTypeNames);
newProperties[key] = {
type: 'array',
items: { $ref: `#/definitions/${nestedInterfaceName}` },
};
}
else {
newProperties[key] = value;
}
}
else {
// 普通字段直接保留
newProperties[key] = value;
}
}
schema.properties = newProperties;
}
// 将提取的 definitions 添加到 schema
schema.definitions = {
...(schema.definitions || {}),
...definitions,
};
return schema;
}
/**
* 根据 JSONSchema 对象生产 TypeScript 类型定义。
*
* @param jsonSchema JSONSchema 对象
* @param typeName 类型名称
* @returns TypeScript 类型定义
*/
export async function jsonSchemaToType(jsonSchema, typeName, usedTypeNames = {}) {
if (isEmpty(jsonSchema)) {
return `export interface ${typeName} {}`;
}
if (jsonSchema.__is_any__) {
delete jsonSchema.__is_any__;
return `export type ${typeName} = any`;
}
// JSTT 会转换 typeName,因此传入一个全大写的假 typeName,生成代码后再替换回真正的 typeName
const fakeTypeName = 'THISISAFAKETYPENAME';
const schema = jsonSchemaToJSTTJsonSchema(cloneDeepFast(jsonSchema), typeName);
// 提取所有嵌套结构为独立的接口schema & 枚举支持
const transformedSchema = extractNestedTypes(schema, typeName, {}, usedTypeNames);
const code = await compile(transformedSchema, fakeTypeName, JSTTOptions);
return code.replace(fakeTypeName, typeName).trim();
}
/** 没有读懂 */
export function getRequestDataJsonSchema(interfaceInfo, customTypeMapping) {
let jsonSchema;
// 处理表单数据(仅 POST 类接口)
if (isPostLikeMethod(interfaceInfo.method)) {
switch (interfaceInfo.req_body_type) {
case RequestBodyType.form:
jsonSchema = propDefinitionsToJsonSchema(interfaceInfo.req_body_form.map(item => ({
name: item.name,
required: item.required === Required.true,
type: (item.type === RequestFormItemType.file
? 'file'
: 'string'),
comment: item.desc,
})), customTypeMapping);
break;
case RequestBodyType.json:
if (interfaceInfo.req_body_other) {
jsonSchema = interfaceInfo.req_body_is_json_schema
? jsonSchemaStringToJsonSchema(interfaceInfo.req_body_other, customTypeMapping)
: jsonToJsonSchema(JSON5.parse(interfaceInfo.req_body_other), customTypeMapping);
}
break;
default:
/* istanbul ignore next */
break;
}
}
// 处理查询数据
if (isArray(interfaceInfo.req_query) && interfaceInfo.req_query.length) {
const queryJsonSchema = propDefinitionsToJsonSchema(interfaceInfo.req_query.map(item => ({
name: item.name,
required: item.required === Required.true,
type: item.type || 'string',
comment: item.desc,
})), customTypeMapping);
/* istanbul ignore else */
if (jsonSchema) {
jsonSchema.properties = {
...jsonSchema.properties,
...queryJsonSchema.properties,
};
jsonSchema.required = [
...(Array.isArray(jsonSchema.required) ? jsonSchema.required : []),
...(Array.isArray(queryJsonSchema.required)
? queryJsonSchema.required
: []),
];
}
else {
jsonSchema = queryJsonSchema;
}
}
// 处理路径参数
if (isArray(interfaceInfo.req_params) && interfaceInfo.req_params.length) {
const paramsJsonSchema = propDefinitionsToJsonSchema(interfaceInfo.req_params.map(item => ({
name: item.name,
required: true,
type: item.type || 'string',
comment: item.desc,
})), customTypeMapping);
/* istanbul ignore else */
if (jsonSchema) {
jsonSchema.properties = {
...jsonSchema.properties,
...paramsJsonSchema.properties,
};
jsonSchema.required = [
...(Array.isArray(jsonSchema.required) ? jsonSchema.required : []),
...(Array.isArray(paramsJsonSchema.required)
? paramsJsonSchema.required
: []),
];
}
else {
jsonSchema = paramsJsonSchema;
}
}
return jsonSchema || {};
}
/** 没有读懂 */
export function getResponseDataJsonSchema(interfaceInfo, customTypeMapping, dataKey) {
let jsonSchema = {};
switch (interfaceInfo.res_body_type) {
case ResponseBodyType.json:
if (interfaceInfo.res_body) {
jsonSchema = interfaceInfo.res_body_is_json_schema
? jsonSchemaStringToJsonSchema(interfaceInfo.res_body, customTypeMapping)
: mockjsTemplateToJsonSchema(JSON5.parse(interfaceInfo.res_body), customTypeMapping);
}
break;
default:
jsonSchema = { __is_any__: true };
break;
}
if (dataKey && jsonSchema) {
jsonSchema = reachJsonSchema(jsonSchema, dataKey);
}
return jsonSchema;
}
export function reachJsonSchema(jsonSchema, path) {
var _a;
let last = jsonSchema;
for (const segment of castArray(path)) {
const _last = (_a = last.properties) === null || _a === void 0 ? void 0 : _a[segment];
if (!_last) {
return jsonSchema;
}
last = _last;
}
return last;
}
export function sortByWeights(list) {
list.sort((a, b) => {
const x = a.weights.length > b.weights.length ? b : a;
const minLen = Math.min(a.weights.length, b.weights.length);
const maxLen = Math.max(a.weights.length, b.weights.length);
x.weights.push(...new Array(maxLen - minLen).fill(0));
const w = a.weights.reduce((w, _, i) => {
if (w === 0) {
w = a.weights[i] - b.weights[i];
}
return w;
}, 0);
return w;
});
return list;
}
export function isGetLikeMethod(method) {
return (method === Method.GET || method === Method.OPTIONS || method === Method.HEAD);
}
export function isPostLikeMethod(method) {
return !isGetLikeMethod(method);
}
export async function getPrettier(cwd) {
const projectPrettierPath = path.join(cwd, 'node_modules/prettier');
if (await fs.pathExists(projectPrettierPath)) {
return require(projectPrettierPath);
}
return require('prettier');
}
export async function getPrettierOptions() {
const prettierOptions = {
parser: 'typescript',
printWidth: 120,
tabWidth: 2,
singleQuote: true,
semi: false,
trailingComma: 'all',
bracketSpacing: false,
endOfLine: 'lf',
};
// 测试时跳过本地配置的解析
if (process.env.JEST_WORKER_ID) {
return prettierOptions;
}
const [prettierConfigPathErr, prettierConfigPath] = await run(() => prettier.resolveConfigFile());
if (prettierConfigPathErr || !prettierConfigPath) {
return prettierOptions;
}
const [prettierConfigErr, prettierConfig] = await run(() => prettier.resolveConfig(prettierConfigPath));
if (prettierConfigErr || !prettierConfig) {
return prettierOptions;
}
return {
...prettierOptions,
...prettierConfig,
parser: 'typescript',
};
}
export const getCachedPrettierOptions = memoize(getPrettierOptions);
export async function httpGet(url, query) {
const _url = new URL(url);
if (query) {
Object.keys(query).forEach(key => {
_url.searchParams.set(key, query[key]);
});
}
url = _url.toString();
const res = await nodeFetch(url, {
method: 'GET',
agent: new ProxyAgent(),
});
return res.json();
}