commit 15ed8c8b8dc1902a66fa6bf700827411f98e7c07 Author: S.Gromov Date: Sun Oct 26 22:30:58 2025 +0300 feat: инициализация API CodeGen CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации. - Поддержка локальных файлов и URL для спецификаций - Кастомизация имени выходного файла через флаг --name - Генерация типизированного клиента с SWR хуками - Минимальный вывод логов для лучшего UX diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba79468 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed975cc --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# API CodeGen + +CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации. + +## Установка + +```bash +bun install +``` + +## Использование + +```bash +api-codegen -u -i -o [-n ] +``` + +**Аргументы:** +- `-u, --url ` - Базовый URL API +- `-i, --input ` - Путь к OpenAPI файлу (локальный или URL) +- `-o, --output ` - Директория для сохранения файлов +- `-n, --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
{data?.email}
; +} +``` + +## Лицензия + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..84dc324 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/cdn-api.json b/cdn-api.json new file mode 100644 index 0000000..d40068c --- /dev/null +++ b/cdn-api.json @@ -0,0 +1 @@ +{"openapi":"3.0.0","paths":{"/":{"get":{"operationId":"AppController_getHello","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/profile":{"get":{"operationId":"AppController_getProfile","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/auth/register":{"post":{"description":"Создаёт нового пользователя и возвращает JWT токен","operationId":"AuthController_register","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterDto"}}}},"responses":{"201":{"description":"Пользователь успешно зарегистрирован","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"400":{"description":"Некорректные данные или пользователь уже существует"}},"summary":"Регистрация нового пользователя","tags":["Аутентификация"]}},"/auth/login":{"post":{"description":"Аутентифицирует пользователя и возвращает JWT токен","operationId":"AuthController_login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginDto"}}}},"responses":{"200":{"description":"Пользователь успешно авторизован","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"401":{"description":"Неверный email или пароль"}},"summary":"Авторизация пользователя","tags":["Аутентификация"]}},"/auth/me":{"get":{"description":"Возвращает информацию о текущем авторизованном пользователе","operationId":"AuthController_getProfile","parameters":[],"responses":{"200":{"description":"Информация о пользователе","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}},"401":{"description":"Не авторизован"}},"security":[{"JWT-auth":[]}],"summary":"Получение профиля текущего пользователя","tags":["Аутентификация"]}},"/projects":{"post":{"description":"Создаёт новый проект для текущего пользователя","operationId":"ProjectController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectDto"}}}},"responses":{"201":{"description":"Проект успешно создан","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponseDto"}}}},"400":{"description":"Некорректные данные"},"401":{"description":"Не авторизован"}},"security":[{"JWT-auth":[]}],"summary":"Создать новый проект","tags":["Проекты"]},"get":{"description":"Возвращает все проекты текущего пользователя","operationId":"ProjectController_findAll","parameters":[],"responses":{"200":{"description":"Список проектов","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponseDto"}}}}},"401":{"description":"Не авторизован"}},"security":[{"JWT-auth":[]}],"summary":"Получить все проекты","tags":["Проекты"]}},"/projects/{id}":{"get":{"description":"Возвращает информацию о конкретном проекте","operationId":"ProjectController_findOne","parameters":[{"name":"id","required":true,"in":"path","description":"ID проекта","schema":{"example":"123e4567-e89b-12d3-a456-426614174000","type":"string"}}],"responses":{"200":{"description":"Информация о проекте","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponseDto"}}}},"401":{"description":"Не авторизован"},"404":{"description":"Проект не найден"}},"security":[{"JWT-auth":[]}],"summary":"Получить проект по ID","tags":["Проекты"]},"patch":{"description":"Обновляет информацию о проекте","operationId":"ProjectController_update","parameters":[{"name":"id","required":true,"in":"path","description":"ID проекта","schema":{"example":"123e4567-e89b-12d3-a456-426614174000","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectDto"}}}},"responses":{"200":{"description":"Проект успешно обновлён","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponseDto"}}}},"400":{"description":"Некорректные данные"},"401":{"description":"Не авторизован"},"404":{"description":"Проект не найден"}},"security":[{"JWT-auth":[]}],"summary":"Обновить проект","tags":["Проекты"]},"delete":{"description":"Удаляет проект и все связанные данные","operationId":"ProjectController_remove","parameters":[{"name":"id","required":true,"in":"path","description":"ID проекта","schema":{"example":"123e4567-e89b-12d3-a456-426614174000","type":"string"}}],"responses":{"204":{"description":"Проект успешно удалён"},"401":{"description":"Не авторизован"},"404":{"description":"Проект не найден"}},"security":[{"JWT-auth":[]}],"summary":"Удалить проект","tags":["Проекты"]}}},"info":{"title":"CDN API","description":"API документация для CDN сервиса","version":"1.0","contact":{}},"tags":[],"servers":[],"components":{"securitySchemes":{"JWT-auth":{"scheme":"bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Введите JWT токен","in":"header"}},"schemas":{"RegisterDto":{"type":"object","properties":{"email":{"type":"string","description":"Email пользователя","example":"user@example.com"},"password":{"type":"string","description":"Пароль пользователя (минимум 8 символов)","example":"SecurePassword123","minLength":8},"firstName":{"type":"string","description":"Имя пользователя","example":"Иван"},"lastName":{"type":"string","description":"Фамилия пользователя","example":"Иванов"}},"required":["email","password"]},"UserData":{"type":"object","properties":{"id":{"type":"string","description":"ID пользователя","example":"123e4567-e89b-12d3-a456-426614174000"},"email":{"type":"string","description":"Email пользователя","example":"user@example.com"},"firstName":{"type":"string","description":"Имя пользователя","example":"Иван"},"lastName":{"type":"string","description":"Фамилия пользователя","example":"Иванов"},"isEmailVerified":{"type":"boolean","description":"Подтверждён ли email","example":false},"createdAt":{"format":"date-time","type":"string","description":"Дата создания аккаунта","example":"2025-10-19T10:00:00.000Z"}},"required":["id","email","isEmailVerified","createdAt"]},"AuthResponseDto":{"type":"object","properties":{"access_token":{"type":"string","description":"JWT токен для авторизации","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"user":{"description":"Данные пользователя","allOf":[{"$ref":"#/components/schemas/UserData"}]}},"required":["access_token","user"]},"LoginDto":{"type":"object","properties":{"email":{"type":"string","description":"Email пользователя","example":"user@example.com"},"password":{"type":"string","description":"Пароль пользователя","example":"SecurePassword123"}},"required":["email","password"]},"UserResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"ID пользователя","example":"123e4567-e89b-12d3-a456-426614174000"},"email":{"type":"string","description":"Email пользователя","example":"user@example.com"},"firstName":{"type":"string","description":"Имя пользователя","example":"Иван"},"lastName":{"type":"string","description":"Фамилия пользователя","example":"Иванов"},"isEmailVerified":{"type":"boolean","description":"Подтверждён ли email","example":false},"createdAt":{"format":"date-time","type":"string","description":"Дата создания аккаунта","example":"2025-10-19T10:00:00.000Z"},"updatedAt":{"format":"date-time","type":"string","description":"Дата последнего обновления","example":"2025-10-19T10:00:00.000Z"}},"required":["id","email","isEmailVerified","createdAt","updatedAt"]},"CreateProjectDto":{"type":"object","properties":{"name":{"type":"string","description":"Название проекта","example":"Мой CDN проект","maxLength":255},"slug":{"type":"string","description":"Уникальный slug проекта (только латиница, цифры и дефис). Если не указан, генерируется автоматически","example":"my-cdn-project","maxLength":255},"description":{"type":"string","description":"Описание проекта","example":"Проект для хранения статических файлов"},"s3Endpoint":{"type":"string","description":"S3 endpoint URL","example":"https://s3.amazonaws.com"},"s3Bucket":{"type":"string","description":"Название S3 bucket","example":"my-cdn-bucket"},"s3Region":{"type":"string","description":"Регион S3","example":"us-east-1"},"s3AccessKey":{"type":"string","description":"S3 Access Key","example":"AKIAIOSFODNN7EXAMPLE"},"s3SecretKey":{"type":"string","description":"S3 Secret Key","example":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}},"required":["name"]},"ProjectResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"ID проекта","example":"123e4567-e89b-12d3-a456-426614174000"},"name":{"type":"string","description":"Название проекта","example":"Мой CDN проект"},"slug":{"type":"string","description":"Уникальный slug проекта","example":"my-cdn-project"},"description":{"type":"string","description":"Описание проекта","example":"Проект для хранения файлов"},"userId":{"type":"string","description":"ID владельца проекта","example":"123e4567-e89b-12d3-a456-426614174000"},"s3Endpoint":{"type":"string","description":"S3 endpoint URL","example":"https://s3.amazonaws.com"},"s3Bucket":{"type":"string","description":"Название S3 bucket","example":"my-cdn-bucket"},"s3Region":{"type":"string","description":"Регион S3","example":"us-east-1"},"s3AccessKey":{"type":"string","description":"S3 Access Key","example":"AKIAIOSFODNN7EXAMPLE"},"s3SecretKey":{"type":"string","description":"S3 Secret Key (чувствительные данные)","example":"***hidden***"},"isActive":{"type":"boolean","description":"Активен ли проект","example":true},"createdAt":{"format":"date-time","type":"string","description":"Дата создания","example":"2025-10-19T10:00:00.000Z"},"updatedAt":{"format":"date-time","type":"string","description":"Дата последнего обновления","example":"2025-10-19T10:00:00.000Z"}},"required":["id","name","slug","userId","isActive","createdAt","updatedAt"]},"UpdateProjectDto":{"type":"object","properties":{"name":{"type":"string","description":"Название проекта","example":"Обновленное название","maxLength":255},"description":{"type":"string","description":"Описание проекта","example":"Обновленное описание"},"s3Endpoint":{"type":"string","description":"S3 endpoint URL","example":"https://s3.amazonaws.com"},"s3Bucket":{"type":"string","description":"Название S3 bucket","example":"my-cdn-bucket"},"s3Region":{"type":"string","description":"Регион S3","example":"us-east-1"},"s3AccessKey":{"type":"string","description":"S3 Access Key","example":"AKIAIOSFODNN7EXAMPLE"},"s3SecretKey":{"type":"string","description":"S3 Secret Key","example":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"},"isActive":{"type":"boolean","description":"Активен ли проект","example":true}}}}}} \ No newline at end of file diff --git a/example-hooks.tsx b/example-hooks.tsx new file mode 100644 index 0000000..9423b09 --- /dev/null +++ b/example-hooks.tsx @@ -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
Загрузка...
; + if (error) return
Ошибка: {error.message}
; + if (!data) return null; + + return ( +
+

Профиль пользователя

+

Email: {data.email}

+

Имя: {data.firstName} {data.lastName}

+

Email подтверждён: {data.isEmailVerified ? 'Да' : 'Нет'}

+
+ ); +} + +// 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
Загрузка проекта...
; + if (error) return
Ошибка: {error.message}
; + if (!project) return null; + + return ( +
+

{project.name}

+

{project.description}

+

Bucket: {project.s3Bucket}

+

Region: {project.s3Region}

+
+ ); +} + +// Список с автоматической ревалидацией +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
Загрузка списка...
; + if (error) return
Ошибка: {error.message}
; + + return ( +
+

Мои проекты ({projects?.length || 0})

+ +
    + {projects?.map((project) => ( +
  • + {project.name} - {project.slug} +
  • + ))} +
+
+ ); +} + +// ============================================ +// ПРИМЕР 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
Загрузка...
; + if (error) return
Ошибка
; + + return ( +
+

{data?.firstName} {data?.lastName}

+

{data?.email}

+
+ ); +} + +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
Загрузка...
; + + return ( +
+

Проекты

+ {projects?.map((p) => ( +
{p.name}
+ ))} +
+ ); +} + +// ============================================ +// ПРИМЕР 3: УСЛОВНАЯ ЗАГРУЗКА +// ============================================ + +function ConditionalProfile({ userId }: { userId?: string }) { + const profileConfig = api.auth.useGetProfile(); + + const { data } = useSWR( + // Загружаем только если есть userId + userId ? profileConfig.path : null, + () => api.auth.getProfile() + ); + + return data ?
{data.email}
: 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 ( +
+

Всего проектов: {projects?.length || 0}

+ {firstProject && ( +
+

Первый проект:

+

{firstProject.name}

+
+ )} +
+ ); +} + +// ============================================ +// ПРИМЕР 5: СОЗДАНИЕ ХУКА-ОБЁРТКИ +// ============================================ + +// Универсальный хук для всех GET запросов +function useApiQuery( + useConfigFn: () => { path: string; method: 'GET'; secure?: boolean }, + apiFn: () => Promise, + options?: Parameters[2] +) { + const config = useConfigFn(); + return useSWR(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 ( + + + + + ); +} + +export { + UserProfile, + ProjectDetails, + ProjectsList, + UserProfileWithReactQuery, + ProjectsListWithReactQuery, + ConditionalProfile, + DependentQueries, + useApiQuery, +}; + diff --git a/example.ts b/example.ts new file mode 100644 index 0000000..3a7d9cf --- /dev/null +++ b/example.ts @@ -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 }; + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..5fe6fb1 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..b42bffb --- /dev/null +++ b/src/cli.ts @@ -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 ', 'Base API URL (e.g., https://api.example.com)') + .requiredOption('-i, --input ', 'Path to OpenAPI specification file (JSON or YAML)') + .requiredOption('-o, --output ', 'Output directory for generated files') + .option('-n, --name ', 'Name of generated file (without extension)') + .action(async (options) => { + try { + // Создание конфигурации + const config: Partial = { + 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(); + diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..87a55d8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,53 @@ +/** + * Конфигурация генератора API + */ +export interface GeneratorConfig { + /** Базовый URL API */ + apiUrl: string; + /** Путь к файлу OpenAPI спецификации */ + inputPath: string; + /** Путь для сохранения сгенерированных файлов */ + outputPath: string; + /** Имя сгенерированного файла (без расширения) */ + fileName?: string; +} + +/** + * Валидация конфигурации генератора + */ +export function validateConfig(config: Partial): 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; + } +} + diff --git a/src/generator.ts b/src/generator.ts new file mode 100644 index 0000000..89f223e --- /dev/null +++ b/src/generator.ts @@ -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 { + // Убедимся, что выходная директория существует + 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(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; + } +} + diff --git a/src/templates/api.ejs b/src/templates/api.ejs new file mode 100644 index 0000000..5c690ba --- /dev/null +++ b/src/templates/api.ejs @@ -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 %><% if (!config.singleHttpClient) { %> extends HttpClient <% } %> { + +<% if(config.singleHttpClient) { %> + http: HttpClient; + + constructor (http: HttpClient) { + 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 }) %> + + <% } %> + } + <% } %> +<% } %> +} diff --git a/src/templates/data-contract-jsdoc.ejs b/src/templates/data-contract-jsdoc.ejs new file mode 100644 index 0000000..6dcfa91 --- /dev/null +++ b/src/templates/data-contract-jsdoc.ejs @@ -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 %> + +<% } %> + */ +<% } %> diff --git a/src/templates/data-contracts.ejs b/src/templates/data-contracts.ejs new file mode 100644 index 0000000..b4df08a --- /dev/null +++ b/src/templates/data-contracts.ejs @@ -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 %> = Omit & Required> +<% } %> + +<% for (const contract of modelTypes) { %> + <%~ includeFile('./data-contract-jsdoc.ejs', { ...it, data: { ...contract, ...contract.typeData } }) %> + <%~ contract.internal ? '' : 'export'%> <%~ (dataContractTemplates[contract.typeIdentifier] || dataContractTemplates.type)(contract) %> + + +<% } %> diff --git a/src/templates/enum-data-contract.ejs b/src/templates/enum-data-contract.ejs new file mode 100644 index 0000000..e46050c --- /dev/null +++ b/src/templates/enum-data-contract.ejs @@ -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") %> + } +<% } %> diff --git a/src/templates/http-client.ejs b/src/templates/http-client.ejs new file mode 100644 index 0000000..f0b3ac4 --- /dev/null +++ b/src/templates/http-client.ejs @@ -0,0 +1,250 @@ +<% +const { apiConfig, generateResponses, config } = it; +const baseUrl = apiConfig?.baseUrl || ""; +%> + +/** + * Фетчер для SWR + * Принимает URL и возвращает Promise с данными + */ +export const fetcher = (url: string): Promise => { + 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; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** 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 + + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: (securityData: SecurityDataType | null) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse 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 { + public baseUrl: string = "<%~ baseUrl %>"; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: 'same-origin', + headers: {}, + redirect: 'follow', + referrerPolicy: 'no-referrer', + } + + constructor(apiConfig: ApiConfig = {}) { + 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 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 ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params +<% if (config.unwrapResponseData) { %> + }: FullRequestParams): Promise => { +<% } else { %> + }: FullRequestParams): Promise> => { +<% } %> + 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; + 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; +<% } %> + }); + }; +} diff --git a/src/templates/interface-data-contract.ejs b/src/templates/interface-data-contract.ejs new file mode 100644 index 0000000..efddc1c --- /dev/null +++ b/src/templates/interface-data-contract.ejs @@ -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' : ''%>; + <% } %> +} diff --git a/src/templates/object-field-jsdoc.ejs b/src/templates/object-field-jsdoc.ejs new file mode 100644 index 0000000..ec39314 --- /dev/null +++ b/src/templates/object-field-jsdoc.ejs @@ -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 %> + + <% }) %> + */ +<% } %> diff --git a/src/templates/procedure-call.ejs b/src/templates/procedure-call.ejs new file mode 100644 index 0000000..635e1dd --- /dev/null +++ b/src/templates/procedure-call.ejs @@ -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>` + } + default: { + return `Promise` + } + } +} + +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 ? ',' : '' %> +<% } %> diff --git a/src/templates/route-docs.ejs b/src/templates/route-docs.ejs new file mode 100644 index 0000000..3de625a --- /dev/null +++ b/src/templates/route-docs.ejs @@ -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, +} +%> diff --git a/src/templates/route-name.ejs b/src/templates/route-name.ejs new file mode 100644 index 0000000..7470eca --- /dev/null +++ b/src/templates/route-name.ejs @@ -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); +%> diff --git a/src/templates/route-type.ejs b/src/templates/route-type.ejs new file mode 100644 index 0000000..991ab05 --- /dev/null +++ b/src/templates/route-type.ejs @@ -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 %>; +} diff --git a/src/templates/route-types.ejs b/src/templates/route-types.ejs new file mode 100644 index 0000000..453f039 --- /dev/null +++ b/src/templates/route-types.ejs @@ -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 }) %> + <% } %> + } + + <% } %> +<% } %> diff --git a/src/templates/type-data-contract.ejs b/src/templates/type-data-contract.ejs new file mode 100644 index 0000000..3505084 --- /dev/null +++ b/src/templates/type-data-contract.ejs @@ -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; +<% } %> diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000..ceda5fb --- /dev/null +++ b/src/utils/file.ts @@ -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 { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +/** + * Чтение файла как текст + */ +export async function readTextFile(path: string): Promise { + return await readFile(path, 'utf-8'); +} + +/** + * Чтение JSON файла + */ +export async function readJsonFile(path: string): Promise { + // Проверяем, является ли путь 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 { + const dir = dirname(path); + await mkdir(dir, { recursive: true }); + await writeFile(path, content, 'utf-8'); +} + +/** + * Создание директории (рекурсивно) + */ +export async function ensureDir(path: string): Promise { + await mkdir(path, { recursive: true }); +} + +/** + * Получение абсолютного пути + */ +export function resolvePath(path: string): string { + return join(process.cwd(), path); +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +}