Compare commits

...

3 Commits

Author SHA1 Message Date
961c7f0ec1 fix: ослаблена валидация content-type при загрузке спецификации по URL
- убрана строгая проверка content-type (ломала GitHub raw и другие серверы)
- вместо неё парсинг JSON с понятной ошибкой при невалидном ответе
- обновлён README: только npx, добавлен флаг --swr
- версия 1.0.7
2026-04-01 19:21:34 +03:00
2557568b5e fix: исправлен путь к шаблонам, добавлена валидация результата генерации
- путь к шаблонам теперь корректен при установке через npm (resolve(__dirname, 'templates'))
- проверка существования директории шаблонов перед генерацией
- проверка что файлы реально созданы после генерации
- добавлена мета-информация в package.json (автор, репозиторий)
- переименован AI-PROJECT-OVERVIEW.md в AGENTS.md
- версия 1.0.5
2026-04-01 19:03:28 +03:00
8ddf0e1c7f feat: подготовка к публикации npm-пакета
- переименование в @gromlab/api-codegen, версия 1.0.3
- добавлена MIT лицензия
- динамическое чтение версии из package.json
- настроен build с копированием шаблонов и external biome
- загрузка спецификации по URL для извлечения title
- moduleNameFirstTag: true, cleanOutput: false
2026-04-01 18:55:41 +03:00
9 changed files with 144 additions and 81 deletions

21
LICENSE Normal file
View 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.

View File

@@ -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, большие спецификации)
## Лицензия ## Лицензия

View File

@@ -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"
}
} }

View File

@@ -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();

View File

@@ -33,3 +33,5 @@ export function validateConfig(config: Partial<GeneratorConfig>): config is Gene
return true; return true;
} }

View File

@@ -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}`);
}
} }

View File

@@ -64,3 +64,5 @@ export function resolvePath(path: string): string {
return join(process.cwd(), path); return join(process.cwd(), path);
} }

View File

@@ -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 () => {