feat: инициализация API CodeGen
CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации. - Поддержка локальных файлов и URL для спецификаций - Кастомизация имени выходного файла через флаг --name - Генерация типизированного клиента с SWR хуками - Минимальный вывод логов для лучшего UX
This commit is contained in:
52
src/cli.ts
Normal file
52
src/cli.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { validateConfig, type GeneratorConfig } from './config.js';
|
||||
import { generate } from './generator.js';
|
||||
import { fileExists } from './utils/file.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('api-codegen')
|
||||
.description('Generate TypeScript API client from OpenAPI specification')
|
||||
.version('1.0.0')
|
||||
.requiredOption('-u, --url <url>', 'Base API URL (e.g., https://api.example.com)')
|
||||
.requiredOption('-i, --input <path>', 'Path to OpenAPI specification file (JSON or YAML)')
|
||||
.requiredOption('-o, --output <path>', 'Output directory for generated files')
|
||||
.option('-n, --name <name>', 'Name of generated file (without extension)')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
// Создание конфигурации
|
||||
const config: Partial<GeneratorConfig> = {
|
||||
apiUrl: options.url,
|
||||
inputPath: options.input,
|
||||
outputPath: options.output,
|
||||
fileName: options.name,
|
||||
};
|
||||
|
||||
// Валидация конфигурации
|
||||
validateConfig(config);
|
||||
|
||||
// Проверка существования входного файла (только для локальных файлов)
|
||||
if (!config.inputPath!.startsWith('http://') && !config.inputPath!.startsWith('https://')) {
|
||||
if (!(await fileExists(config.inputPath!))) {
|
||||
console.error(chalk.red(`\n❌ Error: Input file not found: ${config.inputPath}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Генерация API
|
||||
await generate(config as GeneratorConfig);
|
||||
|
||||
console.log(chalk.green('\n✨ Done!\n'));
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error:'), error instanceof Error ? error.message : error);
|
||||
console.error();
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
53
src/config.ts
Normal file
53
src/config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Конфигурация генератора API
|
||||
*/
|
||||
export interface GeneratorConfig {
|
||||
/** Базовый URL API */
|
||||
apiUrl: string;
|
||||
/** Путь к файлу OpenAPI спецификации */
|
||||
inputPath: string;
|
||||
/** Путь для сохранения сгенерированных файлов */
|
||||
outputPath: string;
|
||||
/** Имя сгенерированного файла (без расширения) */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация конфигурации генератора
|
||||
*/
|
||||
export function validateConfig(config: Partial<GeneratorConfig>): config is GeneratorConfig {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.apiUrl) {
|
||||
errors.push('API URL is required (--url)');
|
||||
} else if (!isValidUrl(config.apiUrl)) {
|
||||
errors.push('API URL must be a valid URL');
|
||||
}
|
||||
|
||||
if (!config.inputPath) {
|
||||
errors.push('Input path is required (--input)');
|
||||
}
|
||||
|
||||
if (!config.outputPath) {
|
||||
errors.push('Output path is required (--output)');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Configuration validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка валидности URL
|
||||
*/
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
130
src/generator.ts
Normal file
130
src/generator.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { generateApi as swaggerGenerateApi } from 'swagger-typescript-api';
|
||||
import { resolve, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { unlink } from 'fs/promises';
|
||||
import type { GeneratorConfig } from './config.js';
|
||||
import { ensureDir, readJsonFile, writeFileWithDirs } from './utils/file.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Генерация API клиента из OpenAPI спецификации
|
||||
*/
|
||||
export async function generate(config: GeneratorConfig): Promise<void> {
|
||||
// Убедимся, что выходная директория существует
|
||||
await ensureDir(config.outputPath);
|
||||
|
||||
// Путь к кастомным шаблонам
|
||||
const templatesPath = resolve(__dirname, '../src/templates');
|
||||
|
||||
|
||||
// Читаем и модифицируем OpenAPI спецификацию
|
||||
const inputPath = config.inputPath.startsWith('http://') || config.inputPath.startsWith('https://')
|
||||
? config.inputPath
|
||||
: resolve(config.inputPath);
|
||||
const spec = await readJsonFile<any>(inputPath);
|
||||
|
||||
// Определяем имя файла
|
||||
let fileName = config.fileName;
|
||||
if (!fileName) {
|
||||
// Пытаемся получить имя из OpenAPI спецификации
|
||||
fileName = spec.info?.title
|
||||
? spec.info.title.replace(/[^a-zA-Z0-9]/g, '')
|
||||
: 'Api';
|
||||
}
|
||||
|
||||
// Добавляем или обновляем servers с baseUrl
|
||||
spec.servers = [{ url: config.apiUrl }];
|
||||
|
||||
// Сохраняем модифицированную спецификацию во временный файл
|
||||
const tempSpecPath = join(config.outputPath, '.openapi-temp.json');
|
||||
await writeFileWithDirs(tempSpecPath, JSON.stringify(spec, null, 2));
|
||||
|
||||
try {
|
||||
await swaggerGenerateApi({
|
||||
input: tempSpecPath,
|
||||
output: resolve(config.outputPath),
|
||||
fileName: `${fileName}.ts`,
|
||||
httpClientType: 'fetch',
|
||||
modular: false,
|
||||
templates: templatesPath,
|
||||
generateClient: true,
|
||||
generateRouteTypes: true,
|
||||
extractRequestParams: true,
|
||||
extractRequestBody: true,
|
||||
extractEnums: true,
|
||||
cleanOutput: true,
|
||||
singleHttpClient: true,
|
||||
unwrapResponseData: true,
|
||||
defaultResponseAsSuccess: true,
|
||||
enumNamesAsValues: false,
|
||||
moduleNameFirstTag: false,
|
||||
generateUnionEnums: false,
|
||||
extraTemplates: [],
|
||||
addReadonly: false,
|
||||
sortTypes: false,
|
||||
sortRoutes: false,
|
||||
extractResponseError: false,
|
||||
fixInvalidEnumKeyPrefix: 'KEY',
|
||||
silent: false,
|
||||
defaultResponseType: 'void',
|
||||
typePrefix: '',
|
||||
typeSuffix: '',
|
||||
enumKeyPrefix: '',
|
||||
enumKeySuffix: '',
|
||||
extractingOptions: {
|
||||
requestBodySuffix: ['Payload', 'Body', 'Input'],
|
||||
requestParamsSuffix: ['Params'],
|
||||
responseBodySuffix: ['Data', 'Result', 'Output'],
|
||||
responseErrorSuffix: ['Error', 'Fail', 'Fails', 'ErrorData', 'HttpError', 'BadResponse'],
|
||||
},
|
||||
hooks: {
|
||||
onFormatRouteName: (routeInfo, templateRouteName) => {
|
||||
// Убираем префикс с названием контроллера из имени метода
|
||||
// Например: projectControllerUpdate -> update
|
||||
// authControllerLogin -> login
|
||||
const controllerPattern = /^(\w+)Controller(\w+)$/;
|
||||
const match = templateRouteName.match(controllerPattern);
|
||||
|
||||
if (match) {
|
||||
const [, , methodName] = match;
|
||||
// Делаем первую букву строчной
|
||||
return methodName ? methodName.charAt(0).toLowerCase() + methodName.slice(1) : templateRouteName;
|
||||
}
|
||||
|
||||
return templateRouteName;
|
||||
},
|
||||
onInit: (configuration, codeGenProcess) => {
|
||||
// Передаём baseUrl в конфигурацию для шаблонов
|
||||
(configuration as any).apiConfig = (configuration as any).apiConfig || {};
|
||||
(configuration as any).apiConfig.baseUrl = config.apiUrl;
|
||||
return configuration;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Generated files in ${config.outputPath}:`);
|
||||
console.log(` - ${fileName}.ts (API endpoints)`);
|
||||
console.log(' - http-client.ts (HTTP client)');
|
||||
console.log(' - data-contracts.ts (TypeScript types)');
|
||||
|
||||
// Удаляем временный файл
|
||||
try {
|
||||
await unlink(tempSpecPath);
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки удаления
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Generation failed:', error);
|
||||
// Удаляем временный файл даже при ошибке
|
||||
try {
|
||||
await unlink(tempSpecPath);
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки удаления
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
72
src/templates/api.ejs
Normal file
72
src/templates/api.ejs
Normal file
@@ -0,0 +1,72 @@
|
||||
<%
|
||||
const { apiConfig, routes, utils, config } = it;
|
||||
const { info, servers, externalDocs } = apiConfig;
|
||||
const { _, require, formatDescription } = utils;
|
||||
|
||||
const server = (servers && servers[0]) || { url: "" };
|
||||
|
||||
const descriptionLines = _.compact([
|
||||
`@title ${info.title || "No title"}`,
|
||||
info.version && `@version ${info.version}`,
|
||||
info.license && `@license ${_.compact([
|
||||
info.license.name,
|
||||
info.license.url && `(${info.license.url})`,
|
||||
]).join(" ")}`,
|
||||
info.termsOfService && `@termsOfService ${info.termsOfService}`,
|
||||
server.url && `@baseUrl ${server.url}`,
|
||||
externalDocs.url && `@externalDocs ${externalDocs.url}`,
|
||||
info.contact && `@contact ${_.compact([
|
||||
info.contact.name,
|
||||
info.contact.email && `<${info.contact.email}>`,
|
||||
info.contact.url && `(${info.contact.url})`,
|
||||
]).join(" ")}`,
|
||||
info.description && " ",
|
||||
info.description && _.replace(formatDescription(info.description), /\n/g, "\n * "),
|
||||
]);
|
||||
|
||||
%>
|
||||
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./http-client";
|
||||
|
||||
<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>
|
||||
|
||||
<% if (descriptionLines.length) { %>
|
||||
/**
|
||||
<% descriptionLines.forEach((descriptionLine) => { %>
|
||||
* <%~ descriptionLine %>
|
||||
|
||||
<% }) %>
|
||||
*/
|
||||
<% } %>
|
||||
export class <%~ config.apiClassName %><SecurityDataType extends unknown><% if (!config.singleHttpClient) { %> extends HttpClient<SecurityDataType> <% } %> {
|
||||
|
||||
<% if(config.singleHttpClient) { %>
|
||||
http: HttpClient<SecurityDataType>;
|
||||
|
||||
constructor (http: HttpClient<SecurityDataType>) {
|
||||
this.http = http;
|
||||
}
|
||||
<% } %>
|
||||
|
||||
|
||||
<% if (routes.outOfModule) { %>
|
||||
<% for (const route of routes.outOfModule) { %>
|
||||
|
||||
<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>
|
||||
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<% if (routes.combined) { %>
|
||||
<% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %>
|
||||
<%~ moduleName %> = {
|
||||
<% for (const route of combinedRoutes) { %>
|
||||
|
||||
<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>
|
||||
|
||||
<% } %>
|
||||
}
|
||||
<% } %>
|
||||
<% } %>
|
||||
}
|
||||
37
src/templates/data-contract-jsdoc.ejs
Normal file
37
src/templates/data-contract-jsdoc.ejs
Normal file
@@ -0,0 +1,37 @@
|
||||
<%
|
||||
const { data, utils } = it;
|
||||
const { formatDescription, require, _ } = utils;
|
||||
|
||||
const stringify = (value) => _.isObject(value) ? JSON.stringify(value) : _.isString(value) ? `"${value}"` : value;
|
||||
|
||||
const jsDocLines = _.compact([
|
||||
data.title,
|
||||
data.description && formatDescription(data.description),
|
||||
!_.isUndefined(data.deprecated) && data.deprecated && '@deprecated',
|
||||
!_.isUndefined(data.format) && `@format ${data.format}`,
|
||||
!_.isUndefined(data.minimum) && `@min ${data.minimum}`,
|
||||
!_.isUndefined(data.multipleOf) && `@multipleOf ${data.multipleOf}`,
|
||||
!_.isUndefined(data.exclusiveMinimum) && `@exclusiveMin ${data.exclusiveMinimum}`,
|
||||
!_.isUndefined(data.maximum) && `@max ${data.maximum}`,
|
||||
!_.isUndefined(data.minLength) && `@minLength ${data.minLength}`,
|
||||
!_.isUndefined(data.maxLength) && `@maxLength ${data.maxLength}`,
|
||||
!_.isUndefined(data.exclusiveMaximum) && `@exclusiveMax ${data.exclusiveMaximum}`,
|
||||
!_.isUndefined(data.maxItems) && `@maxItems ${data.maxItems}`,
|
||||
!_.isUndefined(data.minItems) && `@minItems ${data.minItems}`,
|
||||
!_.isUndefined(data.uniqueItems) && `@uniqueItems ${data.uniqueItems}`,
|
||||
!_.isUndefined(data.default) && `@default ${stringify(data.default)}`,
|
||||
!_.isUndefined(data.pattern) && `@pattern ${data.pattern}`,
|
||||
!_.isUndefined(data.example) && `@example ${stringify(data.example)}`
|
||||
]).join('\n').split('\n');
|
||||
%>
|
||||
<% if (jsDocLines.every(_.isEmpty)) { %>
|
||||
<% } else if (jsDocLines.length === 1) { %>
|
||||
/** <%~ jsDocLines[0] %> */
|
||||
<% } else if (jsDocLines.length) { %>
|
||||
/**
|
||||
<% for (jsDocLine of jsDocLines) { %>
|
||||
* <%~ jsDocLine %>
|
||||
|
||||
<% } %>
|
||||
*/
|
||||
<% } %>
|
||||
40
src/templates/data-contracts.ejs
Normal file
40
src/templates/data-contracts.ejs
Normal file
@@ -0,0 +1,40 @@
|
||||
<%
|
||||
const { modelTypes, utils, config } = it;
|
||||
const { formatDescription, require, _, Ts } = utils;
|
||||
|
||||
|
||||
const buildGenerics = (contract) => {
|
||||
if (!contract.genericArgs || !contract.genericArgs.length) return '';
|
||||
|
||||
return '<' + contract.genericArgs.map(({ name, default: defaultType, extends: extendsType }) => {
|
||||
return [
|
||||
name,
|
||||
extendsType && `extends ${extendsType}`,
|
||||
defaultType && `= ${defaultType}`,
|
||||
].join('')
|
||||
}).join(',') + '>'
|
||||
}
|
||||
|
||||
const dataContractTemplates = {
|
||||
enum: (contract) => {
|
||||
return `enum ${contract.name} {\r\n${contract.content} \r\n }`;
|
||||
},
|
||||
interface: (contract) => {
|
||||
return `interface ${contract.name}${buildGenerics(contract)} {\r\n${contract.content}}`;
|
||||
},
|
||||
type: (contract) => {
|
||||
return `type ${contract.name}${buildGenerics(contract)} = ${contract.content}`;
|
||||
},
|
||||
}
|
||||
%>
|
||||
|
||||
<% if (config.internalTemplateOptions.addUtilRequiredKeysType) { %>
|
||||
type <%~ config.Ts.CodeGenKeyword.UtilRequiredKeys %><T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
|
||||
<% } %>
|
||||
|
||||
<% for (const contract of modelTypes) { %>
|
||||
<%~ includeFile('./data-contract-jsdoc.ejs', { ...it, data: { ...contract, ...contract.typeData } }) %>
|
||||
<%~ contract.internal ? '' : 'export'%> <%~ (dataContractTemplates[contract.typeIdentifier] || dataContractTemplates.type)(contract) %>
|
||||
|
||||
|
||||
<% } %>
|
||||
18
src/templates/enum-data-contract.ejs
Normal file
18
src/templates/enum-data-contract.ejs
Normal file
@@ -0,0 +1,18 @@
|
||||
<%
|
||||
const { contract, utils, config } = it;
|
||||
const { formatDescription, require, _ } = utils;
|
||||
const { name, $content } = contract;
|
||||
%>
|
||||
<% if (config.generateUnionEnums) { %>
|
||||
export type <%~ name %> = <%~ _.map($content, ({ value }) => value).join(" | ") %>
|
||||
<% } else { %>
|
||||
export enum <%~ name %> {
|
||||
<%~ _.map($content, ({ key, value, description }) => {
|
||||
let formattedDescription = description && formatDescription(description, true);
|
||||
return [
|
||||
formattedDescription && `/** ${formattedDescription} */`,
|
||||
`${key} = ${value}`
|
||||
].filter(Boolean).join("\n");
|
||||
}).join(",\n") %>
|
||||
}
|
||||
<% } %>
|
||||
250
src/templates/http-client.ejs
Normal file
250
src/templates/http-client.ejs
Normal file
@@ -0,0 +1,250 @@
|
||||
<%
|
||||
const { apiConfig, generateResponses, config } = it;
|
||||
const baseUrl = apiConfig?.baseUrl || "";
|
||||
%>
|
||||
|
||||
/**
|
||||
* Фетчер для SWR
|
||||
* Принимает URL и возвращает Promise с данными
|
||||
*/
|
||||
export const fetcher = <T = any>(url: string): Promise<T> => {
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP Error ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
};
|
||||
|
||||
export type QueryParamsType = Record<string | number, any>;
|
||||
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
|
||||
|
||||
export interface FullRequestParams extends Omit<RequestInit, "body"> {
|
||||
/** set parameter to `true` for call `securityWorker` for this request */
|
||||
secure?: boolean;
|
||||
/** request path */
|
||||
path: string;
|
||||
/** content type of request body */
|
||||
type?: ContentType;
|
||||
/** query params */
|
||||
query?: QueryParamsType;
|
||||
/** format of response (i.e. response.json() -> format: "json") */
|
||||
format?: ResponseFormat;
|
||||
/** request body */
|
||||
body?: unknown;
|
||||
/** base url */
|
||||
baseUrl?: string;
|
||||
/** request cancellation token */
|
||||
cancelToken?: CancelToken;
|
||||
}
|
||||
|
||||
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">
|
||||
|
||||
|
||||
export interface ApiConfig<SecurityDataType = unknown> {
|
||||
baseUrl?: string;
|
||||
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
|
||||
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
|
||||
customFetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
|
||||
data: D;
|
||||
error: E;
|
||||
}
|
||||
|
||||
type CancelToken = Symbol | string | number;
|
||||
|
||||
export enum ContentType {
|
||||
Json = "application/json",
|
||||
JsonApi = "application/vnd.api+json",
|
||||
FormData = "multipart/form-data",
|
||||
UrlEncoded = "application/x-www-form-urlencoded",
|
||||
Text = "text/plain",
|
||||
}
|
||||
|
||||
export class HttpClient<SecurityDataType = unknown> {
|
||||
public baseUrl: string = "<%~ baseUrl %>";
|
||||
private securityData: SecurityDataType | null = null;
|
||||
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
|
||||
private abortControllers = new Map<CancelToken, AbortController>();
|
||||
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
|
||||
|
||||
private baseApiParams: RequestParams = {
|
||||
credentials: 'same-origin',
|
||||
headers: {},
|
||||
redirect: 'follow',
|
||||
referrerPolicy: 'no-referrer',
|
||||
}
|
||||
|
||||
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
|
||||
Object.assign(this, apiConfig);
|
||||
}
|
||||
|
||||
public setSecurityData = (data: SecurityDataType | null) => {
|
||||
this.securityData = data;
|
||||
}
|
||||
|
||||
protected encodeQueryParam(key: string, value: any) {
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
|
||||
}
|
||||
|
||||
protected addQueryParam(query: QueryParamsType, key: string) {
|
||||
return this.encodeQueryParam(key, query[key]);
|
||||
}
|
||||
|
||||
protected addArrayQueryParam(query: QueryParamsType, key: string) {
|
||||
const value = query[key];
|
||||
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
|
||||
}
|
||||
|
||||
protected toQueryString(rawQuery?: QueryParamsType): string {
|
||||
const query = rawQuery || {};
|
||||
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
|
||||
return keys
|
||||
.map((key) =>
|
||||
Array.isArray(query[key])
|
||||
? this.addArrayQueryParam(query, key)
|
||||
: this.addQueryParam(query, key),
|
||||
)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
protected addQueryParams(rawQuery?: QueryParamsType): string {
|
||||
const queryString = this.toQueryString(rawQuery);
|
||||
return queryString ? `?${queryString}` : "";
|
||||
}
|
||||
|
||||
private contentFormatters: Record<ContentType, (input: any) => any> = {
|
||||
[ContentType.Json]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
|
||||
[ContentType.JsonApi]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
|
||||
[ContentType.Text]: (input:any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input,
|
||||
[ContentType.FormData]: (input: any) => {
|
||||
if (input instanceof FormData) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return Object.keys(input || {}).reduce((formData, key) => {
|
||||
const property = input[key];
|
||||
formData.append(
|
||||
key,
|
||||
property instanceof Blob ?
|
||||
property :
|
||||
typeof property === "object" && property !== null ?
|
||||
JSON.stringify(property) :
|
||||
`${property}`
|
||||
);
|
||||
return formData;
|
||||
}, new FormData());
|
||||
},
|
||||
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
|
||||
}
|
||||
|
||||
protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
|
||||
return {
|
||||
...this.baseApiParams,
|
||||
...params1,
|
||||
...(params2 || {}),
|
||||
headers: {
|
||||
...(this.baseApiParams.headers || {}),
|
||||
...(params1.headers || {}),
|
||||
...((params2 && params2.headers) || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
|
||||
if (this.abortControllers.has(cancelToken)) {
|
||||
const abortController = this.abortControllers.get(cancelToken);
|
||||
if (abortController) {
|
||||
return abortController.signal;
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(cancelToken, abortController);
|
||||
return abortController.signal;
|
||||
}
|
||||
|
||||
public abortRequest = (cancelToken: CancelToken) => {
|
||||
const abortController = this.abortControllers.get(cancelToken)
|
||||
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
this.abortControllers.delete(cancelToken);
|
||||
}
|
||||
}
|
||||
|
||||
public request = async <T = any, E = any>({
|
||||
body,
|
||||
secure,
|
||||
path,
|
||||
type,
|
||||
query,
|
||||
format,
|
||||
baseUrl,
|
||||
cancelToken,
|
||||
...params
|
||||
<% if (config.unwrapResponseData) { %>
|
||||
}: FullRequestParams): Promise<T> => {
|
||||
<% } else { %>
|
||||
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
|
||||
<% } %>
|
||||
const secureParams = ((typeof secure === 'boolean' ? secure : this.baseApiParams.secure) && this.securityWorker && await this.securityWorker(this.securityData)) || {};
|
||||
const requestParams = this.mergeRequestParams(params, secureParams);
|
||||
const queryString = query && this.toQueryString(query);
|
||||
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
|
||||
const responseFormat = format || requestParams.format;
|
||||
|
||||
return this.customFetch(
|
||||
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
|
||||
{
|
||||
...requestParams,
|
||||
headers: {
|
||||
...(requestParams.headers || {}),
|
||||
...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
|
||||
},
|
||||
signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null,
|
||||
body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
|
||||
}
|
||||
).then(async (response) => {
|
||||
const r = response as HttpResponse<T, E>;
|
||||
r.data = (null as unknown) as T;
|
||||
r.error = (null as unknown) as E;
|
||||
|
||||
const responseToParse = responseFormat ? response.clone() : response;
|
||||
const data = !responseFormat ? r : await responseToParse[responseFormat]()
|
||||
.then((data) => {
|
||||
if (r.ok) {
|
||||
r.data = data;
|
||||
} else {
|
||||
r.error = data;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch((e) => {
|
||||
r.error = e;
|
||||
return r;
|
||||
});
|
||||
|
||||
if (cancelToken) {
|
||||
this.abortControllers.delete(cancelToken);
|
||||
}
|
||||
|
||||
<% if (!config.disableThrowOnError) { %>
|
||||
if (!response.ok) throw data;
|
||||
<% } %>
|
||||
<% if (config.unwrapResponseData) { %>
|
||||
return data.data;
|
||||
<% } else { %>
|
||||
return data;
|
||||
<% } %>
|
||||
});
|
||||
};
|
||||
}
|
||||
10
src/templates/interface-data-contract.ejs
Normal file
10
src/templates/interface-data-contract.ejs
Normal file
@@ -0,0 +1,10 @@
|
||||
<%
|
||||
const { contract, utils } = it;
|
||||
const { formatDescription, require, _ } = utils;
|
||||
%>
|
||||
export interface <%~ contract.name %> {
|
||||
<% for (const field of contract.$content) { %>
|
||||
<%~ includeFile('./object-field-jsdoc.ejs', { ...it, field }) %>
|
||||
<%~ field.name %><%~ field.isRequired ? '' : '?' %>: <%~ field.value %><%~ field.isNullable ? ' | null' : ''%>;
|
||||
<% } %>
|
||||
}
|
||||
28
src/templates/object-field-jsdoc.ejs
Normal file
28
src/templates/object-field-jsdoc.ejs
Normal file
@@ -0,0 +1,28 @@
|
||||
<%
|
||||
const { field, utils } = it;
|
||||
const { formatDescription, require, _ } = utils;
|
||||
|
||||
const comments = _.uniq(
|
||||
_.compact([
|
||||
field.title,
|
||||
field.description,
|
||||
field.deprecated && ` * @deprecated`,
|
||||
!_.isUndefined(field.format) && `@format ${field.format}`,
|
||||
!_.isUndefined(field.minimum) && `@min ${field.minimum}`,
|
||||
!_.isUndefined(field.maximum) && `@max ${field.maximum}`,
|
||||
!_.isUndefined(field.pattern) && `@pattern ${field.pattern}`,
|
||||
!_.isUndefined(field.example) &&
|
||||
`@example ${_.isObject(field.example) ? JSON.stringify(field.example) : field.example}`,
|
||||
]).reduce((acc, comment) => [...acc, ...comment.split(/\n/g)], []),
|
||||
);
|
||||
%>
|
||||
<% if (comments.length === 1) { %>
|
||||
/** <%~ comments[0] %> */
|
||||
<% } else if (comments.length) { %>
|
||||
/**
|
||||
<% comments.forEach(comment => { %>
|
||||
* <%~ comment %>
|
||||
|
||||
<% }) %>
|
||||
*/
|
||||
<% } %>
|
||||
140
src/templates/procedure-call.ejs
Normal file
140
src/templates/procedure-call.ejs
Normal file
@@ -0,0 +1,140 @@
|
||||
<%
|
||||
const { utils, route, config } = it;
|
||||
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
|
||||
const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils;
|
||||
const { parameters, path, method, payload, query, formData, security, requestParams } = route.request;
|
||||
const { type, errorType, contentTypes } = route.response;
|
||||
const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants;
|
||||
const routeDocs = includeFile("./route-docs", { config, route, utils });
|
||||
const queryName = (query && query.name) || "query";
|
||||
const pathParams = _.values(parameters);
|
||||
const pathParamsNames = _.map(pathParams, "name");
|
||||
|
||||
const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH;
|
||||
|
||||
const requestConfigParam = {
|
||||
name: specificArgNameResolver.resolve(RESERVED_REQ_PARAMS_ARG_NAMES),
|
||||
optional: true,
|
||||
type: "RequestParams",
|
||||
defaultValue: "{}",
|
||||
}
|
||||
|
||||
const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`;
|
||||
|
||||
const rawWrapperArgs = config.extractRequestParams ?
|
||||
_.compact([
|
||||
requestParams && {
|
||||
name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
|
||||
optional: false,
|
||||
type: getInlineParseContent(requestParams),
|
||||
},
|
||||
...(!requestParams ? pathParams : []),
|
||||
payload,
|
||||
requestConfigParam,
|
||||
]) :
|
||||
_.compact([
|
||||
...pathParams,
|
||||
query,
|
||||
payload,
|
||||
requestConfigParam,
|
||||
])
|
||||
|
||||
const wrapperArgs = _
|
||||
// Sort by optionality
|
||||
.sortBy(rawWrapperArgs, [o => o.optional])
|
||||
.map(argToTmpl)
|
||||
.join(', ')
|
||||
|
||||
// RequestParams["type"]
|
||||
const requestContentKind = {
|
||||
"JSON": "ContentType.Json",
|
||||
"JSON_API": "ContentType.JsonApi",
|
||||
"URL_ENCODED": "ContentType.UrlEncoded",
|
||||
"FORM_DATA": "ContentType.FormData",
|
||||
"TEXT": "ContentType.Text",
|
||||
}
|
||||
// RequestParams["format"]
|
||||
const responseContentKind = {
|
||||
"JSON": '"json"',
|
||||
"IMAGE": '"blob"',
|
||||
"FORM_DATA": isFetchTemplate ? '"formData"' : '"document"'
|
||||
}
|
||||
|
||||
const bodyTmpl = _.get(payload, "name") || null;
|
||||
const queryTmpl = (query != null && queryName) || null;
|
||||
const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null;
|
||||
const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null;
|
||||
const securityTmpl = security ? 'true' : null;
|
||||
|
||||
const describeReturnType = () => {
|
||||
if (!config.toJS) return "";
|
||||
|
||||
switch(config.httpClientType) {
|
||||
case HTTP_CLIENT.AXIOS: {
|
||||
return `Promise<AxiosResponse<${type}>>`
|
||||
}
|
||||
default: {
|
||||
return `Promise<HttpResponse<${type}, ${errorType}>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
||||
|
||||
%>
|
||||
/**
|
||||
<%~ routeDocs.description %>
|
||||
|
||||
*<% /* Here you can add some other JSDoc tags */ %>
|
||||
|
||||
<%~ routeDocs.lines %>
|
||||
|
||||
*/
|
||||
<% if (isValidIdentifier(route.routeName.usage)) { %><%~ route.routeName.usage %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ route.routeName.usage %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> =>
|
||||
<%~ config.singleHttpClient ? 'this.http.request' : 'this.request' %><<%~ type %>, <%~ errorType %>>({
|
||||
path: `<%~ path %>`,
|
||||
method: '<%~ _.upperCase(method) %>',
|
||||
<%~ queryTmpl ? `query: ${queryTmpl},` : '' %>
|
||||
<%~ bodyTmpl ? `body: ${bodyTmpl},` : '' %>
|
||||
<%~ securityTmpl ? `secure: ${securityTmpl},` : '' %>
|
||||
<%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %>
|
||||
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
|
||||
...<%~ _.get(requestConfigParam, "name") %>,
|
||||
})<%~ route.namespace ? ',' : '' %>
|
||||
<%
|
||||
// Генерируем use* функцию для GET запросов
|
||||
const isGetRequest = _.upperCase(method) === 'GET';
|
||||
if (isGetRequest) {
|
||||
const useMethodName = 'use' + _.upperFirst(route.routeName.usage);
|
||||
const argsWithoutParams = rawWrapperArgs.filter(arg => arg.name !== requestConfigParam.name);
|
||||
const useWrapperArgs = _
|
||||
.sortBy(argsWithoutParams, [o => o.optional])
|
||||
.map(argToTmpl)
|
||||
.join(', ');
|
||||
|
||||
// Определяем обязательные параметры для проверки
|
||||
const requiredArgs = argsWithoutParams.filter(arg => !arg.optional);
|
||||
const requiredArgsNames = requiredArgs.map(arg => {
|
||||
// Извлекаем имя из деструктуризации типа "{ id, ...query }"
|
||||
const match = arg.name.match(/^\{\s*([^,}]+)/);
|
||||
return match ? match[1].trim() : arg.name;
|
||||
});
|
||||
|
||||
// Генерируем условие для проверки всех обязательных параметров
|
||||
const hasRequiredArgs = requiredArgsNames.length > 0;
|
||||
const conditionCheck = hasRequiredArgs
|
||||
? requiredArgsNames.join(' && ')
|
||||
: 'true';
|
||||
%>
|
||||
|
||||
/**
|
||||
* SWR hook для <%~ route.routeName.usage %>
|
||||
<%~ routeDocs.lines %>
|
||||
*/
|
||||
<% if (isValidIdentifier(useMethodName)) { %><%~ useMethodName %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ useMethodName %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ useWrapperArgs %>) => {
|
||||
return useSWR<<%~ type %>>(
|
||||
<%~ conditionCheck %> ? `<%~ path %>` : null,
|
||||
fetcher
|
||||
);
|
||||
}<%~ route.namespace ? ',' : '' %>
|
||||
<% } %>
|
||||
30
src/templates/route-docs.ejs
Normal file
30
src/templates/route-docs.ejs
Normal file
@@ -0,0 +1,30 @@
|
||||
<%
|
||||
const { config, route, utils } = it;
|
||||
const { _, formatDescription, fmtToJSDocLine, pascalCase, require } = utils;
|
||||
const { raw, request, routeName } = route;
|
||||
|
||||
const jsDocDescription = raw.description ?
|
||||
` * @description ${formatDescription(raw.description, true)}` :
|
||||
fmtToJSDocLine('No description', { eol: false });
|
||||
const jsDocLines = _.compact([
|
||||
_.size(raw.tags) && ` * @tags ${raw.tags.join(", ")}`,
|
||||
` * @name ${pascalCase(routeName.usage)}`,
|
||||
raw.summary && ` * @summary ${raw.summary}`,
|
||||
` * @request ${_.upperCase(request.method)}:${raw.route}`,
|
||||
raw.deprecated && ` * @deprecated`,
|
||||
routeName.duplicate && ` * @originalName ${routeName.original}`,
|
||||
routeName.duplicate && ` * @duplicate`,
|
||||
request.security && ` * @secure`,
|
||||
...(config.generateResponses && raw.responsesTypes.length
|
||||
? raw.responsesTypes.map(
|
||||
({ type, status, description, isSuccess }) =>
|
||||
` * @response \`${status}\` \`${_.replace(_.replace(type, /\/\*/g, "\\*"), /\*\//g, "*\\")}\` ${description}`,
|
||||
)
|
||||
: []),
|
||||
]).map(str => str.trimEnd()).join("\n");
|
||||
|
||||
return {
|
||||
description: jsDocDescription,
|
||||
lines: jsDocLines,
|
||||
}
|
||||
%>
|
||||
43
src/templates/route-name.ejs
Normal file
43
src/templates/route-name.ejs
Normal file
@@ -0,0 +1,43 @@
|
||||
<%
|
||||
const { routeInfo, utils } = it;
|
||||
const {
|
||||
operationId,
|
||||
method,
|
||||
route,
|
||||
moduleName,
|
||||
responsesTypes,
|
||||
description,
|
||||
tags,
|
||||
summary,
|
||||
pathArgs,
|
||||
} = routeInfo;
|
||||
const { _, fmtToJSDocLine, require } = utils;
|
||||
|
||||
const methodAliases = {
|
||||
get: (pathName, hasPathInserts) =>
|
||||
_.camelCase(`${pathName}_${hasPathInserts ? "detail" : "list"}`),
|
||||
post: (pathName, hasPathInserts) => _.camelCase(`${pathName}_create`),
|
||||
put: (pathName, hasPathInserts) => _.camelCase(`${pathName}_update`),
|
||||
patch: (pathName, hasPathInserts) => _.camelCase(`${pathName}_partial_update`),
|
||||
delete: (pathName, hasPathInserts) => _.camelCase(`${pathName}_delete`),
|
||||
};
|
||||
|
||||
const createCustomOperationId = (method, route, moduleName) => {
|
||||
const hasPathInserts = /\{(\w){1,}\}$/g.test(route);
|
||||
const splittedRouteBySlash = _.compact(_.replace(route, /\{(\w){1,}\}/g, "").split("/"));
|
||||
const routeParts = (splittedRouteBySlash.length > 1
|
||||
? splittedRouteBySlash.splice(1)
|
||||
: splittedRouteBySlash
|
||||
).join("_");
|
||||
return routeParts.length > 3 && methodAliases[method]
|
||||
? methodAliases[method](routeParts, hasPathInserts)
|
||||
: _.camelCase(_.lowerCase(method) + "_" + [moduleName].join("_")) || "index";
|
||||
};
|
||||
|
||||
if (operationId)
|
||||
return _.camelCase(operationId);
|
||||
if (route === "/")
|
||||
return _.camelCase(`${_.lowerCase(method)}Root`);
|
||||
|
||||
return createCustomOperationId(method, route, moduleName);
|
||||
%>
|
||||
24
src/templates/route-type.ejs
Normal file
24
src/templates/route-type.ejs
Normal file
@@ -0,0 +1,24 @@
|
||||
<%
|
||||
const { route, utils, config } = it;
|
||||
const { _, pascalCase, require } = utils;
|
||||
const { query, payload, pathParams, headers } = route.request;
|
||||
|
||||
const routeDocs = includeFile("./route-docs", { config, route, utils });
|
||||
const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
||||
const routeNamespace = pascalCase(route.routeName.usage);
|
||||
|
||||
%>
|
||||
|
||||
/**
|
||||
<%~ routeDocs.description %>
|
||||
|
||||
<%~ routeDocs.lines %>
|
||||
|
||||
*/
|
||||
export namespace <% if (isValidIdentifier(routeNamespace)) { %><%~ routeNamespace %><% } else { %>"<%~ routeNamespace %>"<% } %> {
|
||||
export type RequestParams = <%~ (pathParams && pathParams.type) || '{}' %>;
|
||||
export type RequestQuery = <%~ (query && query.type) || '{}' %>;
|
||||
export type RequestBody = <%~ (payload && payload.type) || 'never' %>;
|
||||
export type RequestHeaders = <%~ (headers && headers.type) || '{}' %>;
|
||||
export type ResponseBody = <%~ route.response.type %>;
|
||||
}
|
||||
32
src/templates/route-types.ejs
Normal file
32
src/templates/route-types.ejs
Normal file
@@ -0,0 +1,32 @@
|
||||
<%
|
||||
const { utils, config, routes, modelTypes } = it;
|
||||
const { _, pascalCase } = utils;
|
||||
const dataContracts = config.modular ? _.map(modelTypes, "name") : [];
|
||||
%>
|
||||
|
||||
<% if (dataContracts.length) { %>
|
||||
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
|
||||
<% } %>
|
||||
|
||||
<%
|
||||
/* TODO: outOfModule, combined should be attributes of route, which will allow to avoid duplication of code */
|
||||
%>
|
||||
|
||||
<% if (routes.outOfModule) { %>
|
||||
<% for (const { routes: outOfModuleRoutes = [] } of routes.outOfModule) { %>
|
||||
<% for (const route of outOfModuleRoutes) { %>
|
||||
<%~ includeFile('./route-type.ejs', { ...it, route }) %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<% if (routes.combined) { %>
|
||||
<% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %>
|
||||
export namespace <%~ pascalCase(moduleName) %> {
|
||||
<% for (const route of combinedRoutes) { %>
|
||||
<%~ includeFile('./route-type.ejs', { ...it, route }) %>
|
||||
<% } %>
|
||||
}
|
||||
|
||||
<% } %>
|
||||
<% } %>
|
||||
15
src/templates/type-data-contract.ejs
Normal file
15
src/templates/type-data-contract.ejs
Normal file
@@ -0,0 +1,15 @@
|
||||
<%
|
||||
const { contract, utils } = it;
|
||||
const { formatDescription, require, _ } = utils;
|
||||
|
||||
%>
|
||||
<% if (contract.$content.length) { %>
|
||||
export type <%~ contract.name %> = {
|
||||
<% for (const field of contract.$content) { %>
|
||||
<%~ includeFile('./object-field-jsdoc.ejs', { ...it, field }) %>
|
||||
<%~ field.field %>;
|
||||
<% } %>
|
||||
}<%~ utils.isNeedToAddNull(contract) ? ' | null' : ''%>
|
||||
<% } else { %>
|
||||
export type <%~ contract.name %> = Record<string, any>;
|
||||
<% } %>
|
||||
66
src/utils/file.ts
Normal file
66
src/utils/file.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { mkdir, writeFile, readFile, access } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { constants } from 'fs';
|
||||
|
||||
/**
|
||||
* Проверка существования файла
|
||||
*/
|
||||
export async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение файла как текст
|
||||
*/
|
||||
export async function readTextFile(path: string): Promise<string> {
|
||||
return await readFile(path, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение JSON файла
|
||||
*/
|
||||
export async function readJsonFile<T = unknown>(path: string): Promise<T> {
|
||||
// Проверяем, является ли путь URL
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
// Используем нативный fetch в Node.js 18+
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch OpenAPI spec from ${path}: ${response.statusText}`);
|
||||
}
|
||||
const content = await response.text();
|
||||
return JSON.parse(content) as T;
|
||||
}
|
||||
|
||||
// Иначе читаем из локального файла
|
||||
const content = await readTextFile(path);
|
||||
return JSON.parse(content) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись файла с автоматическим созданием директорий
|
||||
*/
|
||||
export async function writeFileWithDirs(path: string, content: string): Promise<void> {
|
||||
const dir = dirname(path);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path, content, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание директории (рекурсивно)
|
||||
*/
|
||||
export async function ensureDir(path: string): Promise<void> {
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение абсолютного пути
|
||||
*/
|
||||
export function resolvePath(path: string): string {
|
||||
return join(process.cwd(), path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user