feat: инициализация API CodeGen

CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации.
- Поддержка локальных файлов и URL для спецификаций
- Кастомизация имени выходного файла через флаг --name
- Генерация типизированного клиента с SWR хуками
- Минимальный вывод логов для лучшего UX
This commit is contained in:
2025-10-26 22:30:58 +03:00
commit 15ed8c8b8d
26 changed files with 1854 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# API CodeGen
CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации.
## Установка
```bash
bun install
```
## Использование
```bash
api-codegen -u <URL> -i <INPUT> -o <OUTPUT> [-n <NAME>]
```
**Аргументы:**
- `-u, --url <url>` - Базовый URL API
- `-i, --input <path>` - Путь к OpenAPI файлу (локальный или URL)
- `-o, --output <path>` - Директория для сохранения файлов
- `-n, --name <name>` - Имя сгенерированного файла (опционально)
**Примеры:**
```bash
# Локальный файл
api-codegen -u https://api.example.com -i ./openapi.json -o ./src/api
# URL на спецификацию
api-codegen -u https://api.example.com -i https://petstore.swagger.io/v2/swagger.json -o ./src/api
# С кастомным именем файла
api-codegen -u https://api.example.com -i ./openapi.json -o ./src/api -n MyApiClient
```
## Пример использования
```typescript
import { Api, HttpClient } from './src/api/Api';
const httpClient = new HttpClient();
httpClient.setSecurityData({ token: 'jwt-token' });
const api = new Api(httpClient);
// GET запрос
const user = await api.auth.getProfile();
// POST запрос
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>;
}
```
## Лицензия
MIT

227
bun.lock Normal file
View File

@@ -0,0 +1,227 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "api-codegen",
"dependencies": {
"@biomejs/wasm-bundler": "^2.3.0",
"@biomejs/wasm-web": "^2.3.0",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ejs": "^3.1.10",
"js-yaml": "^4.1.0",
"swagger-typescript-api": "^13.0.22",
},
"devDependencies": {
"@types/bun": "latest",
"@types/ejs": "^3.1.5",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@biomejs/js-api": ["@biomejs/js-api@3.0.0", "", { "peerDependencies": { "@biomejs/wasm-bundler": "^2.2.0", "@biomejs/wasm-nodejs": "^2.2.0", "@biomejs/wasm-web": "^2.2.0" }, "optionalPeers": ["@biomejs/wasm-bundler", "@biomejs/wasm-nodejs", "@biomejs/wasm-web"] }, "sha512-5QcGJFj9IO+yXl76ICjvkdE38uxRcTDsBzcCZHEZ+ma+Te/nbvJg4A3KtAds9HCrEF0JKLWiyjMhAbqazuJvYA=="],
"@biomejs/wasm-bundler": ["@biomejs/wasm-bundler@2.3.0", "", {}, "sha512-rk4EkBd37o8UzIKMo39n/WXnnTA9bSlhOSZ93IAfTHuLqjG9p1BNtosYheH/7VyKn/PncgmiMQ7xboqzWK8XqA=="],
"@biomejs/wasm-nodejs": ["@biomejs/wasm-nodejs@2.2.6", "", {}, "sha512-lUEcvW+2eyMTgCofknBT04AvY7KkQSqKe3Nv40+ZxWVlStsPB0v2RWLu7xks69Yxcb3TfNGsfq21OWkdrmO2NQ=="],
"@biomejs/wasm-web": ["@biomejs/wasm-web@2.3.0", "", {}, "sha512-P48lp1O4jO89VSCrjf5VpRFJNNl+vaB/bLtST5E3mvalbOjUB6pE0byfTxL1ZRt8TLmFUKfLZbnnyC8ON5KNAA=="],
"@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/ejs": ["@types/ejs@3.1.5", "", {}, "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
"@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/swagger-schema-official": ["@types/swagger-schema-official@2.0.25", "", {}, "sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"c12": ["c12@3.3.1", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ=="],
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eta": ["eta@3.5.0", "", {}, "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug=="],
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"http2-client": ["http2-client@1.3.5", "", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-fetch-h2": ["node-fetch-h2@2.3.0", "", { "dependencies": { "http2-client": "^1.2.5" } }, "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-readfiles": ["node-readfiles@0.2.0", "", { "dependencies": { "es6-promise": "^3.2.1" } }, "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"oas-kit-common": ["oas-kit-common@1.0.8", "", { "dependencies": { "fast-safe-stringify": "^2.0.7" } }, "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ=="],
"oas-linter": ["oas-linter@3.2.2", "", { "dependencies": { "@exodus/schemasafe": "^1.0.0-rc.2", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ=="],
"oas-resolver": ["oas-resolver@2.5.6", "", { "dependencies": { "node-fetch-h2": "^2.3.0", "oas-kit-common": "^1.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "resolve": "resolve.js" } }, "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ=="],
"oas-schema-walker": ["oas-schema-walker@1.1.5", "", {}, "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ=="],
"oas-validator": ["oas-validator@5.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "oas-kit-common": "^1.0.8", "oas-linter": "^3.2.2", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "reftools": "^1.1.9", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@2.0.0", "", {}, "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
"should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
"should-format": ["should-format@3.0.3", "", { "dependencies": { "should-type": "^1.3.0", "should-type-adaptors": "^1.0.1" } }, "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q=="],
"should-type": ["should-type@1.4.0", "", {}, "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ=="],
"should-type-adaptors": ["should-type-adaptors@1.1.0", "", { "dependencies": { "should-type": "^1.3.0", "should-util": "^1.0.0" } }, "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA=="],
"should-util": ["should-util@1.0.1", "", {}, "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"swagger-schema-official": ["swagger-schema-official@2.0.0-bab6bed", "", {}, "sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA=="],
"swagger-typescript-api": ["swagger-typescript-api@13.2.16", "", { "dependencies": { "@biomejs/js-api": "3.0.0", "@biomejs/wasm-nodejs": "2.2.6", "@types/lodash": "^4.17.20", "@types/swagger-schema-official": "^2.0.25", "c12": "^3.3.0", "citty": "^0.1.6", "consola": "^3.4.2", "eta": "^3.5.0", "lodash": "^4.17.21", "nanoid": "^5.1.6", "openapi-types": "^12.1.3", "swagger-schema-official": "2.0.0-bab6bed", "swagger2openapi": "^7.0.8", "typescript": "~5.9.3", "yaml": "^2.8.1" }, "bin": { "sta": "./dist/cli.js", "swagger-typescript-api": "./dist/cli.js" } }, "sha512-PbjfCbNMx1mxqLamUpMA96fl2HJQh9Q5qRWyVRXmZslRJFHBkMfAj7OnEv6IOtSnmD+TXp073yBhKWiUxqeLhQ=="],
"swagger2openapi": ["swagger2openapi@7.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "swagger2openapi": "swagger2openapi.js", "oas-validate": "oas-validate.js", "boast": "boast.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="],
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"bun-types/@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
"oas-linter/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"oas-resolver/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"oas-validator/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"swagger2openapi/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

1
cdn-api.json Normal file

File diff suppressed because one or more lines are too long

282
example-hooks.tsx Normal file
View File

@@ -0,0 +1,282 @@
/**
* Примеры использования сгенерированных use* функций
* с SWR и React Query
*/
import { Api, HttpClient } from './output/Api';
import useSWR from 'swr';
import { useQuery } from '@tanstack/react-query';
// ============================================
// НАСТРОЙКА API КЛИЕНТА
// ============================================
const httpClient = new HttpClient({
baseUrl: 'https://cdn.example.com',
});
// Устанавливаем токен (например, из localStorage)
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
httpClient.setSecurityData({ token });
}
}
const api = new Api(httpClient);
// ============================================
// ПРИМЕР 1: ИСПОЛЬЗОВАНИЕ С SWR
// ============================================
// Простой GET запрос без параметров
function UserProfile() {
const profileConfig = api.auth.useGetProfile();
const { data, error, isLoading } = useSWR(
profileConfig.path, // Ключ для кеша
() => api.auth.getProfile() // Функция для загрузки данных
);
if (isLoading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
if (!data) return null;
return (
<div>
<h1>Профиль пользователя</h1>
<p>Email: {data.email}</p>
<p>Имя: {data.firstName} {data.lastName}</p>
<p>Email подтверждён: {data.isEmailVerified ? 'Да' : 'Нет'}</p>
</div>
);
}
// GET запрос с параметрами
function ProjectDetails({ projectId }: { projectId: string }) {
const projectConfig = api.projects.useFindOne({ id: projectId });
const { data: project, error, isLoading } = useSWR(
[projectConfig.path, projectId], // Составной ключ
() => api.projects.findOne({ id: projectId })
);
if (isLoading) return <div>Загрузка проекта...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
if (!project) return null;
return (
<div>
<h2>{project.name}</h2>
<p>{project.description}</p>
<p>Bucket: {project.s3Bucket}</p>
<p>Region: {project.s3Region}</p>
</div>
);
}
// Список с автоматической ревалидацией
function ProjectsList() {
const projectsConfig = api.projects.useFindAll();
const { data: projects, error, isLoading, mutate } = useSWR(
projectsConfig.path,
() => api.projects.findAll(),
{
refreshInterval: 5000, // Обновлять каждые 5 секунд
revalidateOnFocus: true, // Обновлять при фокусе на окно
}
);
const handleCreateProject = async () => {
await api.projects.create({
name: 'Новый проект',
description: 'Описание',
});
// Обновляем список
mutate();
};
if (isLoading) return <div>Загрузка списка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return (
<div>
<h2>Мои проекты ({projects?.length || 0})</h2>
<button onClick={handleCreateProject}>Создать проект</button>
<ul>
{projects?.map((project) => (
<li key={project.id}>
{project.name} - {project.slug}
</li>
))}
</ul>
</div>
);
}
// ============================================
// ПРИМЕР 2: ИСПОЛЬЗОВАНИЕ С REACT QUERY
// ============================================
function UserProfileWithReactQuery() {
const profileConfig = api.auth.useGetProfile();
const { data, error, isLoading } = useQuery({
queryKey: [profileConfig.path],
queryFn: () => api.auth.getProfile(),
});
if (isLoading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка</div>;
return (
<div>
<h1>{data?.firstName} {data?.lastName}</h1>
<p>{data?.email}</p>
</div>
);
}
function ProjectsListWithReactQuery() {
const projectsConfig = api.projects.useFindAll();
const { data: projects, isLoading } = useQuery({
queryKey: [projectsConfig.path],
queryFn: () => api.projects.findAll(),
staleTime: 5 * 60 * 1000, // 5 минут
refetchInterval: 30000, // Обновлять каждые 30 секунд
});
if (isLoading) return <div>Загрузка...</div>;
return (
<div>
<h2>Проекты</h2>
{projects?.map((p) => (
<div key={p.id}>{p.name}</div>
))}
</div>
);
}
// ============================================
// ПРИМЕР 3: УСЛОВНАЯ ЗАГРУЗКА
// ============================================
function ConditionalProfile({ userId }: { userId?: string }) {
const profileConfig = api.auth.useGetProfile();
const { data } = useSWR(
// Загружаем только если есть userId
userId ? profileConfig.path : null,
() => api.auth.getProfile()
);
return data ? <div>{data.email}</div> : null;
}
// ============================================
// ПРИМЕР 4: ЗАВИСИМЫЕ ЗАПРОСЫ
// ============================================
function DependentQueries() {
// Сначала получаем список проектов
const projectsConfig = api.projects.useFindAll();
const { data: projects } = useSWR(
projectsConfig.path,
() => api.projects.findAll()
);
// Затем получаем первый проект (только когда список загружен)
const firstProjectId = projects?.[0]?.id;
const projectConfig = firstProjectId
? api.projects.useFindOne({ id: firstProjectId })
: null;
const { data: firstProject } = useSWR(
projectConfig ? [projectConfig.path, firstProjectId] : null,
() => firstProjectId ? api.projects.findOne({ id: firstProjectId }) : null
);
return (
<div>
<h3>Всего проектов: {projects?.length || 0}</h3>
{firstProject && (
<div>
<h4>Первый проект:</h4>
<p>{firstProject.name}</p>
</div>
)}
</div>
);
}
// ============================================
// ПРИМЕР 5: СОЗДАНИЕ ХУКА-ОБЁРТКИ
// ============================================
// Универсальный хук для всех GET запросов
function useApiQuery<T>(
useConfigFn: () => { path: string; method: 'GET'; secure?: boolean },
apiFn: () => Promise<T>,
options?: Parameters<typeof useSWR>[2]
) {
const config = useConfigFn();
return useSWR<T>(config.path, apiFn, options);
}
// Использование
function MyComponent() {
const { data, error, isLoading } = useApiQuery(
api.auth.useGetProfile,
api.auth.getProfile,
{ revalidateOnFocus: true }
);
// ...
}
// ============================================
// ПРИМЕР 6: ПОЛЬЗОВАТЕЛЬСКИЙ FETCHER ДЛЯ SWR
// ============================================
// Создаём универсальный fetcher
const apiFetcher = async (key: string | string[]) => {
const path = Array.isArray(key) ? key[0] : key;
// Находим соответствующий метод API
// В реальном приложении можно использовать маппинг
return api.auth.getProfile(); // пример
};
// SWRConfig для всего приложения
import { SWRConfig } from 'swr';
function App() {
return (
<SWRConfig
value={{
fetcher: apiFetcher,
revalidateOnFocus: false,
dedupingInterval: 2000,
}}
>
<UserProfile />
<ProjectsList />
</SWRConfig>
);
}
export {
UserProfile,
ProjectDetails,
ProjectsList,
UserProfileWithReactQuery,
ProjectsListWithReactQuery,
ConditionalProfile,
DependentQueries,
useApiQuery,
};

136
example.ts Normal file
View File

@@ -0,0 +1,136 @@
/**
* Пример использования сгенерированного API клиента
*/
import { Api, HttpClient } from './output/Api';
// 1. Создание HTTP клиента с базовыми настройками
const httpClient = new HttpClient({
baseUrl: 'https://cdn.example.com', // Базовый URL (уже установлен при генерации)
baseApiParams: {
headers: {
'Content-Type': 'application/json',
},
},
});
// 2. Создание API клиента
const api = new Api(httpClient);
// 3. Пример использования
async function registerUser() {
try {
const result = await api.auth.register({
email: 'user@example.com',
password: 'SecurePassword123',
firstName: 'Иван',
lastName: 'Иванов',
});
console.log('Пользователь зарегистрирован:', result);
return result;
} catch (error) {
console.error('Ошибка регистрации:', error);
throw error;
}
}
async function loginUser() {
try {
const result = await api.auth.login({
email: 'user@example.com',
password: 'SecurePassword123',
});
console.log('Авторизация успешна');
// Сохраняем токен для последующих запросов
httpClient.setSecurityData({ token: result.access_token });
return result;
} catch (error) {
console.error('Ошибка авторизации:', error);
throw error;
}
}
async function getUserProfile() {
try {
const profile = await api.auth.getProfile();
console.log('Профиль пользователя:', profile);
return profile;
} catch (error) {
console.error('Ошибка получения профиля:', error);
throw error;
}
}
async function createProject() {
try {
const project = await api.projects.create({
name: 'Мой CDN проект',
description: 'Проект для хранения статических файлов',
s3Endpoint: 'https://s3.amazonaws.com',
s3Bucket: 'my-cdn-bucket',
s3Region: 'us-east-1',
s3AccessKey: 'AKIAIOSFODNN7EXAMPLE',
s3SecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
});
console.log('Проект создан:', project);
return project;
} catch (error) {
console.error('Ошибка создания проекта:', error);
throw error;
}
}
async function getAllProjects() {
try {
const projects = await api.projects.findAll();
console.log('Список проектов:', projects);
return projects;
} catch (error) {
console.error('Ошибка получения проектов:', error);
throw error;
}
}
// Главная функция для демонстрации
async function main() {
console.log('🚀 Пример использования API клиента\n');
// 1. Регистрация
console.log('1. Регистрация пользователя...');
await registerUser();
console.log('✅ Готово\n');
// 2. Авторизация
console.log('2. Авторизация...');
await loginUser();
console.log('✅ Готово\n');
// 3. Получение профиля
console.log('3. Получение профиля...');
await getUserProfile();
console.log('✅ Готово\n');
// 4. Создание проекта
console.log('4. Создание проекта...');
await createProject();
console.log('✅ Готово\n');
// 5. Получение всех проектов
console.log('5. Получение списка проектов...');
await getAllProjects();
console.log('✅ Готово\n');
console.log('✨ Все операции выполнены успешно!');
}
// Запуск примера (раскомментируйте для выполнения)
// main().catch(console.error);
export { registerUser, loginUser, getUserProfile, createProject, getAllProjects };

1
index.ts Normal file
View File

@@ -0,0 +1 @@
console.log("Hello via Bun!");

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "api-codegen",
"version": "1.0.0",
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
"type": "module",
"bin": {
"api-codegen": "./dist/cli.js"
},
"scripts": {
"build": "bun build src/cli.ts --target=node --outdir=dist --format=esm",
"dev": "bun run src/cli.ts",
"test": "bun test"
},
"dependencies": {
"@biomejs/wasm-bundler": "^2.3.0",
"@biomejs/wasm-web": "^2.3.0",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ejs": "^3.1.10",
"js-yaml": "^4.1.0",
"swagger-typescript-api": "^13.0.22"
},
"devDependencies": {
"@types/bun": "latest",
"@types/ejs": "^3.1.5",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2"
},
"peerDependencies": {
"typescript": "^5"
},
"keywords": [
"openapi",
"swagger",
"api",
"codegen",
"typescript",
"generator",
"cli"
],
"author": "",
"license": "MIT"
}

52
src/cli.ts Normal file
View 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
View 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
View 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
View 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 }) %>
<% } %>
}
<% } %>
<% } %>
}

View 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 %>
<% } %>
*/
<% } %>

View 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) %>
<% } %>

View 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") %>
}
<% } %>

View 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;
<% } %>
});
};
}

View 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' : ''%>;
<% } %>
}

View 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 %>
<% }) %>
*/
<% } %>

View 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 ? ',' : '' %>
<% } %>

View 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,
}
%>

View 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);
%>

View 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 %>;
}

View 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 }) %>
<% } %>
}
<% } %>
<% } %>

View 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
View 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);
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}