Compare commits
3 Commits
3c3ac80713
...
961c7f0ec1
| Author | SHA1 | Date | |
|---|---|---|---|
| 961c7f0ec1 | |||
| 2557568b5e | |||
| 8ddf0e1c7f |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Gromlab
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
88
README.md
88
README.md
@@ -1,42 +1,39 @@
|
|||||||
# API CodeGen
|
# @gromlab/api-codegen
|
||||||
|
|
||||||
CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации.
|
CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации.
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
api-codegen -u <URL> -i <INPUT> -o <OUTPUT> [-n <NAME>]
|
npx @gromlab/api-codegen -i <INPUT> -o <OUTPUT> [-n <NAME>] [--swr]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Аргументы:**
|
**Аргументы:**
|
||||||
- `-u, --url <url>` - Базовый URL API
|
- `-i, --input <path>` - Путь к OpenAPI файлу (локальный файл или URL)
|
||||||
- `-i, --input <path>` - Путь к OpenAPI файлу (локальный или URL)
|
|
||||||
- `-o, --output <path>` - Директория для сохранения файлов
|
- `-o, --output <path>` - Директория для сохранения файлов
|
||||||
- `-n, --name <name>` - Имя сгенерированного файла (опционально)
|
- `-n, --name <name>` - Имя сгенерированного файла (опционально, по умолчанию из `spec.info.title`)
|
||||||
|
- `--swr` - Генерировать SWR хуки для React
|
||||||
|
|
||||||
**Примеры:**
|
**Примеры:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Локальный файл
|
# Локальный файл
|
||||||
api-codegen -u https://api.example.com -i ./openapi.json -o ./src/api
|
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api
|
||||||
|
|
||||||
# URL на спецификацию
|
# URL на спецификацию
|
||||||
api-codegen -u https://api.example.com -i https://petstore.swagger.io/v2/swagger.json -o ./src/api
|
npx @gromlab/api-codegen -i https://httpbin.org/spec.json -o ./src/api
|
||||||
|
|
||||||
# С кастомным именем файла
|
# С кастомным именем файла
|
||||||
api-codegen -u https://api.example.com -i ./openapi.json -o ./src/api -n MyApiClient
|
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi
|
||||||
|
|
||||||
|
# С генерацией SWR хуков
|
||||||
|
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api --swr
|
||||||
```
|
```
|
||||||
|
|
||||||
## Пример использования
|
## Пример использования сгенерированного кода
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Api, HttpClient } from './src/api/Api';
|
import { Api, HttpClient } from './src/api/MyApi';
|
||||||
|
|
||||||
const httpClient = new HttpClient();
|
const httpClient = new HttpClient();
|
||||||
httpClient.setSecurityData({ token: 'jwt-token' });
|
httpClient.setSecurityData({ token: 'jwt-token' });
|
||||||
@@ -48,12 +45,6 @@ const user = await api.auth.getProfile();
|
|||||||
|
|
||||||
// POST запрос
|
// POST запрос
|
||||||
const result = await api.auth.login({ email, password });
|
const result = await api.auth.login({ email, password });
|
||||||
|
|
||||||
// React + SWR
|
|
||||||
function Profile() {
|
|
||||||
const { data } = useSWR('/auth/me', () => api.auth.getProfile());
|
|
||||||
return <div>{data?.email}</div>;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
@@ -65,49 +56,24 @@ bun run build
|
|||||||
|
|
||||||
### Тестирование
|
### Тестирование
|
||||||
|
|
||||||
Проект использует комплексную систему тестирования с максимальным покрытием (~72 тестовых кейса).
|
|
||||||
|
|
||||||
**Запуск всех тестов:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# Все тесты
|
||||||
bun test
|
bun test
|
||||||
|
|
||||||
|
# Юнит тесты
|
||||||
|
bun run test:unit
|
||||||
|
|
||||||
|
# Интеграционные тесты
|
||||||
|
bun run test:integration
|
||||||
|
|
||||||
|
# Watch режим
|
||||||
|
bun run test:watch
|
||||||
|
|
||||||
|
# С coverage
|
||||||
|
bun run test:coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
**Только юнит тесты:**
|
Подробная документация по тестированию в [`tests/README.md`](tests/README.md).
|
||||||
```bash
|
|
||||||
bun test:unit
|
|
||||||
```
|
|
||||||
|
|
||||||
**Только интеграционные тесты:**
|
|
||||||
```bash
|
|
||||||
bun test:integration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Watch режим:**
|
|
||||||
```bash
|
|
||||||
bun test:watch
|
|
||||||
```
|
|
||||||
|
|
||||||
**С coverage:**
|
|
||||||
```bash
|
|
||||||
bun test:coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
Подробная документация по тестированию доступна в [`tests/README.md`](tests/README.md).
|
|
||||||
|
|
||||||
### Структура тестов
|
|
||||||
|
|
||||||
- **Юнит тесты** - CLI, генератор, утилиты, валидация
|
|
||||||
- **Интеграционные тесты** - E2E генерация, сгенерированный клиент
|
|
||||||
- **Тестовые фикстуры** - 7 OpenAPI спецификаций для различных сценариев
|
|
||||||
- **Mock сервер** - для тестирования HTTP запросов
|
|
||||||
|
|
||||||
**Покрываемые сценарии:**
|
|
||||||
- ✅ CLI команды и обработка ошибок
|
|
||||||
- ✅ Генерация TypeScript кода
|
|
||||||
- ✅ Компиляция сгенерированного кода
|
|
||||||
- ✅ HTTP запросы с mock сервером
|
|
||||||
- ✅ Аутентификация (Bearer tokens)
|
|
||||||
- ✅ Edge cases (Unicode, большие спецификации)
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
30
package.json
30
package.json
@@ -1,19 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "api-codegen",
|
"name": "@gromlab/api-codegen",
|
||||||
"version": "1.0.0",
|
"version": "1.0.7",
|
||||||
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
|
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"api-codegen": "./dist/cli.js"
|
"api-codegen": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/cli.ts --target=node --outdir=dist --format=esm",
|
"build": "bun build src/cli.ts --target=node --outdir=dist --format=esm --external=@biomejs/* && cp -r src/templates dist/",
|
||||||
"dev": "bun run src/cli.ts",
|
"dev": "bun run src/cli.ts",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:unit": "bun test tests/unit",
|
"test:unit": "bun test tests/unit",
|
||||||
"test:integration": "bun test tests/integration",
|
"test:integration": "bun test tests/integration",
|
||||||
"test:watch": "bun test --watch",
|
"test:watch": "bun test --watch",
|
||||||
"test:coverage": "bun test --coverage"
|
"test:coverage": "bun test --coverage",
|
||||||
|
"prepublishOnly": "bun run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@biomejs/wasm-bundler": "^2.3.0",
|
"@biomejs/wasm-bundler": "^2.3.0",
|
||||||
@@ -49,6 +57,14 @@
|
|||||||
"generator",
|
"generator",
|
||||||
"cli"
|
"cli"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "S.Gromov",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"homepage": "https://gromlab.ru/gromov/api-codegen",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gromlab.ru/gromov/api-codegen.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gromlab.ru/gromov/api-codegen/issues"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/cli.ts
11
src/cli.ts
@@ -2,16 +2,23 @@
|
|||||||
|
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
import { validateConfig, type GeneratorConfig } from './config.js';
|
import { validateConfig, type GeneratorConfig } from './config.js';
|
||||||
import { generate } from './generator.js';
|
import { generate } from './generator.js';
|
||||||
import { fileExists } from './utils/file.js';
|
import { fileExists } from './utils/file.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('api-codegen')
|
.name('api-codegen')
|
||||||
.description('Generate TypeScript API client from OpenAPI specification')
|
.description('Generate TypeScript API client from OpenAPI specification')
|
||||||
.version('1.0.0')
|
.version(pkg.version)
|
||||||
.requiredOption('-i, --input <path>', 'Path to OpenAPI specification file (JSON or YAML)')
|
.requiredOption('-i, --input <path>', 'Path to OpenAPI specification file (JSON or YAML)')
|
||||||
.requiredOption('-o, --output <path>', 'Output directory for generated files')
|
.requiredOption('-o, --output <path>', 'Output directory for generated files')
|
||||||
.option('-n, --name <name>', 'Name of generated file (without extension)')
|
.option('-n, --name <name>', 'Name of generated file (without extension)')
|
||||||
@@ -50,3 +57,5 @@ program
|
|||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ export function validateConfig(config: Partial<GeneratorConfig>): config is Gene
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { generateApi as swaggerGenerateApi } from 'swagger-typescript-api';
|
import { generateApi as swaggerGenerateApi } from 'swagger-typescript-api';
|
||||||
import { resolve } from 'path';
|
import { resolve, join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
import type { GeneratorConfig } from './config.js';
|
import type { GeneratorConfig } from './config.js';
|
||||||
import { ensureDir, readJsonFile } from './utils/file.js';
|
import { ensureDir, readJsonFile } from './utils/file.js';
|
||||||
|
|
||||||
@@ -16,7 +17,9 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
await ensureDir(config.outputPath);
|
await ensureDir(config.outputPath);
|
||||||
|
|
||||||
// Путь к кастомным шаблонам
|
// Путь к кастомным шаблонам
|
||||||
const templatesPath = resolve(__dirname, '../src/templates');
|
// В dev-режиме (bun run src/cli.ts): __dirname = .../src, шаблоны в ./templates
|
||||||
|
// В собранной версии (dist/cli.js): __dirname = .../dist, шаблоны в ./templates (скопированы при сборке)
|
||||||
|
const templatesPath = resolve(__dirname, 'templates');
|
||||||
|
|
||||||
// Проверяем тип входного пути
|
// Проверяем тип входного пути
|
||||||
const isUrl = config.inputPath.startsWith('http://') || config.inputPath.startsWith('https://');
|
const isUrl = config.inputPath.startsWith('http://') || config.inputPath.startsWith('https://');
|
||||||
@@ -28,7 +31,22 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
|
|
||||||
if (isUrl) {
|
if (isUrl) {
|
||||||
url = config.inputPath;
|
url = config.inputPath;
|
||||||
// Для URL не читаем спецификацию заранее, swagger-typescript-api сделает это сам
|
// Загружаем спецификацию для получения info.title
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch OpenAPI spec from ${url}: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
spec = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse OpenAPI spec from ${url} as JSON. ` +
|
||||||
|
`Response starts with: "${text.slice(0, 50)}..."`
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
inputPath = resolve(config.inputPath);
|
inputPath = resolve(config.inputPath);
|
||||||
spec = await readJsonFile<any>(inputPath);
|
spec = await readJsonFile<any>(inputPath);
|
||||||
@@ -43,11 +61,22 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
: 'Api';
|
: 'Api';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем, что директория с шаблонами существует
|
||||||
|
if (!existsSync(templatesPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Templates directory not found: ${templatesPath}. ` +
|
||||||
|
`Make sure the package is built correctly (run "bun run build").`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDir = resolve(config.outputPath);
|
||||||
|
const outputFileName = `${fileName}.ts`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await swaggerGenerateApi({
|
const { files } = await swaggerGenerateApi({
|
||||||
...(isUrl ? { url } : { input: inputPath }),
|
...(isUrl ? { url } : { input: inputPath }),
|
||||||
output: resolve(config.outputPath),
|
output: outputDir,
|
||||||
fileName: `${fileName}.ts`,
|
fileName: outputFileName,
|
||||||
httpClientType: 'fetch',
|
httpClientType: 'fetch',
|
||||||
modular: false,
|
modular: false,
|
||||||
templates: templatesPath,
|
templates: templatesPath,
|
||||||
@@ -56,12 +85,12 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
extractRequestParams: true,
|
extractRequestParams: true,
|
||||||
extractRequestBody: true,
|
extractRequestBody: true,
|
||||||
extractEnums: true,
|
extractEnums: true,
|
||||||
cleanOutput: true,
|
cleanOutput: false,
|
||||||
singleHttpClient: true,
|
singleHttpClient: true,
|
||||||
unwrapResponseData: true,
|
unwrapResponseData: true,
|
||||||
defaultResponseAsSuccess: true,
|
defaultResponseAsSuccess: true,
|
||||||
enumNamesAsValues: false,
|
enumNamesAsValues: false,
|
||||||
moduleNameFirstTag: false,
|
moduleNameFirstTag: true,
|
||||||
generateUnionEnums: false,
|
generateUnionEnums: false,
|
||||||
extraTemplates: [],
|
extraTemplates: [],
|
||||||
addReadonly: false,
|
addReadonly: false,
|
||||||
@@ -110,10 +139,26 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Генерация успешна
|
// Проверяем, что файлы были сгенерированы
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'Generation completed but no files were produced. ' +
|
||||||
|
'Check that the OpenAPI specification is valid.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что выходной файл существует на диске
|
||||||
|
const outputFilePath = join(outputDir, outputFileName);
|
||||||
|
if (!existsSync(outputFilePath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Generation completed but output file was not created: ${outputFilePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Generation failed:', error);
|
if (error instanceof Error && error.message.startsWith('Generation completed')) {
|
||||||
throw error;
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Generation failed: ${error instanceof Error ? error.message : error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,3 +64,5 @@ export function resolvePath(path: string): string {
|
|||||||
return join(process.cwd(), path);
|
return join(process.cwd(), path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
import { setupTest } from '../helpers/setup.js';
|
import { setupTest } from '../helpers/setup.js';
|
||||||
import { FIXTURES } from '../helpers/fixtures.js';
|
import { FIXTURES } from '../helpers/fixtures.js';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
@@ -8,6 +9,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const pkg = JSON.parse(readFileSync(join(dirname(__filename), '../../package.json'), 'utf-8'));
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const CLI_PATH = join(__dirname, '../../src/cli.ts');
|
const CLI_PATH = join(__dirname, '../../src/cli.ts');
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ describe('CLI', () => {
|
|||||||
test('должен отображать версию с --version', async () => {
|
test('должен отображать версию с --version', async () => {
|
||||||
const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']);
|
const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']);
|
||||||
|
|
||||||
expect(stdout).toContain('1.0.0');
|
expect(stdout).toContain(pkg.version);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('должен отображать help с --help', async () => {
|
test('должен отображать help с --help', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user