diff --git a/README.md b/README.md index 0153e6f..bae4c52 100644 --- a/README.md +++ b/README.md @@ -9,22 +9,132 @@ Run the following commands: npm install --save-dev @map-colonies/openapi-helpers supertest prettier openapi-typescript @types/express ``` -## types-generator -The package contains a script that wraps the `openapi-typescript` package and generates types for the OpenAPI schema. The script also formats the generated types using `prettier`. -The command structure is as follows: +## CLI Usage + +The package provides a unified CLI for generating TypeScript types and error classes from OpenAPI specifications. All code generation is now performed using the `generate` command, which supports subcommands for types and errors. + +#### CLI Arguments Reference + +**Positional Arguments:** +For both `generate types` and `generate errors` commands, the positional arguments are: + +- ``: Path to the OpenAPI YAML or JSON file to use as the source schema. +- ``: Path to the file where the generated code will be written. + +These arguments are required and must be provided in the order shown. + +**Optional Arguments:** + +For `generate types`: +- `-f, --format`: Format the generated types using Prettier +- `-t, --add-typed-request-handler`: Add the TypedRequestHandler type to the generated types + +For `generate errors`: +- `-f, --format`: Format the generated code using Prettier +- `-e, --errors-output `: Specify what to generate (default: all) + - `all`: generate both error classes and error code mapping + - `map`: generate only the error code mapping + - `classes`: generate only the error classes + +### Generate Types + +Generate TypeScript types from an OpenAPI schema: ```bash -npx @map-colonies/openapi-helpers --format --add-typed-request-handler +npx @map-colonies/openapi-helpers generate types [options] ``` For example: ```bash -npx @map-colonies/openapi-helpers ./openapi3.yaml ./src/openapi.d.ts --format --add-typed-request-handler +npx @map-colonies/openapi-helpers generate types ./openapi3.yaml ./src/openapi.d.ts --format --add-typed-request-handler +``` + +Options: +- `-f, --format` - Format the generated types using `prettier`. +- `-t, --add-typed-request-handler` - Add the `TypedRequestHandler` type to the generated types. + +### Generate Errors + +Generate error classes and error code mappings from an OpenAPI schema: +```bash +npx @map-colonies/openapi-helpers generate errors [options] +``` + +For example: +```bash +npx @map-colonies/openapi-helpers generate errors ./openapi3.yaml ./src/errors.ts --format +``` + + +Options: +- `-f, --format` - Format the generated code using `prettier`. +- `-e, --errors-output ` - Specify what to generate: + - `all` (default): generate both error classes and error code mapping + - `map`: generate only the error code mapping + - `classes`: generate only the error classes + +### Help and Examples + +To see all available commands and options: +```bash +npx @map-colonies/openapi-helpers --help +npx @map-colonies/openapi-helpers generate --help +npx @map-colonies/openapi-helpers generate types --help +npx @map-colonies/openapi-helpers generate errors --help +``` + + +#### Example: Run all generations + +You can run both types and errors generation in sequence: +```bash +npx @map-colonies/openapi-helpers generate types ./openapi3.yaml ./src/openapi.d.ts --format --add-typed-request-handler +npx @map-colonies/openapi-helpers generate errors ./openapi3.yaml ./src/errors.ts --format --errors-output all +``` + + + +## Programmatic Support + + +> [!NOTE] +> **Programmatic usage of the CLI (importing and using the generators directly) is only supported in ECMAScript modules (ESM).** CommonJS is not supported for direct imports. + +The code generators (`generateTypes.mts` and `generateErrors.mts`) now support functional programming patterns. You can inject custom transformation logic or AST manipulation by providing functional arguments, making the generators more flexible and composable for advanced use cases. + + +### API Usage + +You can import and use the generators directly in your own scripts for full functional programming flexibility: + +```typescript +import { generateTypes, generateErrors } from '@map-colonies/openapi-helpers/generators'; + +// Generate types +await generateTypes( + 'openapi3.yaml', + 'src/openapi.d.ts', + { + shouldFormat: true, + addTypedRequestHandler: true, + // inject?: string, + // transform?: (schemaObject, metadata) => ... + } +); + +// Generate errors +await generateErrors( + 'openapi3.yaml', + 'src/errors.ts', + { + shouldFormat: true, + includeMapping: true, + includeErrorClasses: true + } +); ``` -### options -- `--format` - format the generated types using `prettier`. -- `--add-typed-request-handler` - add the `TypedRequestHandler` type to the generated types. +You can pass custom `inject` or `transform` functions to `generateTypes` for advanced AST/code manipulation, enabling highly composable and functional workflows. ## TypedRequestHandler The package contains a wrapper for the `express` types package that provides autocomplete for all the request handlers to the API based on the OpenAPI schema. The TypedRequestHandler is initialized with the types generated by `openapi-typescript`, and is configured based on operation name or method and path. diff --git a/package-lock.json b/package-lock.json index 0c97499..e2b5ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,17 @@ "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.1.1", + "@commander-js/extra-typings": "^14.0.0", + "@map-colonies/read-pkg": "^1.0.0", "change-case": "^5.4.4", + "commander": "^14.0.0", "oas-normalize": "^14.1.2", + "ora": "^8.2.0", "ts-essentials": "^10.1.1", "yaml": "^2.8.0" }, "bin": { - "openapi-helpers": "dist/generator/generateTypes.mjs" + "openapi-helpers": "dist/cli/entrypoint.mjs" }, "devDependencies": { "@commitlint/cli": "^19.8.1", @@ -624,6 +628,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@commander-js/extra-typings": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", + "integrity": "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==", + "license": "MIT", + "peerDependencies": { + "commander": "~14.0.0" + } + }, "node_modules/@commitlint/cli": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", @@ -2110,6 +2123,18 @@ "node": ">=20" } }, + "node_modules/@map-colonies/read-pkg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@map-colonies/read-pkg/-/read-pkg-1.0.0.tgz", + "integrity": "sha512-powpYwGoy1x+uYa9wvBIwTQBufv2Rzm35IiYlsyZbh8yIq3e2PIxwonAmZ6hleaDAuu35BS5STAnWCUTcMKBiQ==", + "license": "ISC", + "dependencies": { + "type-fest": "^4.33.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@map-colonies/tsconfig": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@map-colonies/tsconfig/-/tsconfig-1.0.1.tgz", @@ -4023,6 +4048,33 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4089,6 +4141,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/commitlint": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/commitlint/-/commitlint-19.8.1.tgz", @@ -5305,6 +5366,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5788,6 +5861,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5840,6 +5925,18 @@ "node": ">=8" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6973,6 +7070,46 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", @@ -7177,6 +7314,18 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7537,18 +7686,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/openapi-typescript/node_modules/type-fest": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.24.0.tgz", - "integrity": "sha512-spAaHzc6qre0TlZQQ2aA/nGMe+2Z/wyGk5Z+Ru2VUfdNwT6kWO6TjevOlpebsATEG1EIQ2sOiDszud3lO5mt/Q==", - "peer": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -7566,6 +7703,91 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8139,6 +8361,49 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8602,6 +8867,18 @@ "node": ">= 0.8" } }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9006,6 +9283,18 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/package.json b/package.json index 2538a83..4915801 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,13 @@ "./typedRequestHandler": { "default": "./dist/typedRequestHandler/typedRequestHandler.js", "types": "./dist/typedRequestHandler/typedRequestHandler.d.ts" + }, + "./generators": { + "import": "./dist/generator/index.mjs", + "types": "./dist/generator/index.d.mts" } }, - "bin": "./dist/generator/generateTypes.mjs", + "bin": "./dist/cli/entrypoint.mjs", "scripts": { "format": "prettier --check .", "format:fix": "prettier --write .", @@ -26,6 +30,7 @@ "generate:test:types": "npm run build && node dist/generator/generateTypes.mjs tests/openapi3.yaml tests/types.d.ts", "clean": "rimraf dist", "prepublish": "npm run build", + "prepack": "npm run build", "prepare": "husky", "docs": "typedoc" }, @@ -50,8 +55,12 @@ "homepage": "https://github.com/MapColonies/openapi-helpers#readme", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.1.1", + "@commander-js/extra-typings": "^14.0.0", + "@map-colonies/read-pkg": "^1.0.0", "change-case": "^5.4.4", + "commander": "^14.0.0", "oas-normalize": "^14.1.2", + "ora": "^8.2.0", "ts-essentials": "^10.1.1", "yaml": "^2.8.0" }, diff --git a/src/cli/entrypoint.mts b/src/cli/entrypoint.mts new file mode 100644 index 0000000..2e04378 --- /dev/null +++ b/src/cli/entrypoint.mts @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import { setTimeout as sleep } from 'node:timers/promises'; +import { program } from '@commander-js/extra-typings'; +import { generateTypes } from '../generator/generateTypes.mjs'; +import { generateErrors } from '../generator/generateErrors.mjs'; +import ora from 'ora'; +import { PACKAGE_VERSION } from '../common/constants.js'; + +const errorOutput = ['all', 'map', 'classes'] as const; +type ErrorsOutput = (typeof errorOutput)[number]; + +function isErrorsOutput(value: string): value is ErrorsOutput { + return errorOutput.includes(value as ErrorsOutput); +} + +const SECOND = 1000; +program.name('openapi-helpers').description('Generate TypeScript types and error classes from OpenAPI specifications').version(PACKAGE_VERSION); + +const command = program.command('generate').description('Generate code artifacts (types, error classes) from OpenAPI specifications'); + +command + .command('types') + .description('Generate TypeScript types from OpenAPI spec') + .argument('', 'Path to the OpenAPI specification file') + .argument('', 'Path where the generated types will be saved') + .option('-f, --format', 'Format the generated code using Prettier') + .option('-t, --add-typed-request-handler', 'Add typed request handler types to the generated output') + .action( + async ( + openapiPath: string, + destinationPath: string, + options: { + format?: boolean; + addTypedRequestHandler?: boolean; + } + ) => { + try { + const spinner = ora('Generating types').start(); + await generateTypes(openapiPath, destinationPath, { shouldFormat: options.format, addTypedRequestHandler: options.addTypedRequestHandler }); + await sleep(SECOND); + spinner.stop(); + console.log('Types generated successfully'); + } catch (error) { + console.error('Error generating types:', error); + process.exit(1); + } + } + ); + +command + .command('errors') + .description('Generate error classes from OpenAPI spec') + .argument('', 'Path to the OpenAPI specification file') + .argument('', 'Path where the generated error classes will be saved') + .option('-f, --format', 'Format the generated code using Prettier') + .option('-e, --errors-output ', 'Specify the errors output type', 'all') + .action(async (openapiPath: string, destinationPath: string, options) => { + try { + if (!isErrorsOutput(options.errorsOutput)) { + console.error(`Invalid errors output type: ${options.errorsOutput}`); + process.exit(1); + } + const includeMapping = options.errorsOutput === 'map' || options.errorsOutput === 'all'; + const includeErrorClasses = options.errorsOutput === 'classes' || options.errorsOutput === 'all'; + + const spinner = ora('Generating errors').start(); + + await generateErrors(openapiPath, destinationPath, { + shouldFormat: options.format, + includeMapping, + includeErrorClasses, + }); + + await sleep(SECOND); + spinner.stop(); + + console.log('Errors generated successfully'); + } catch (error) { + console.error('Error generating errors:', error); + process.exit(1); + } + }); + +// Add examples to the help +program.addHelpText( + 'after', + ` +Examples: + $ openapi-helpers generate types api.yaml types.ts + $ openapi-helpers generate types api.yaml types.ts --format + $ openapi-helpers generate types api.yaml types.ts --add-typed-request-handler + $ openapi-helpers generate types api.yaml types.ts --add-typed-request-handler --format + $ openapi-helpers generate errors api.yaml errors.ts + $ openapi-helpers generate errors api.yaml errors.ts --format + $ openapi-helpers generate errors api.yaml errors.ts --no-mapping + $ openapi-helpers generate errors api.yaml errors.ts --no-error-classes + $ openapi-helpers generate errors api.yaml errors.ts --no-mapping --no-error-classes + $ openapi-helpers --help + $ openapi-helpers generate --help + $ openapi-helpers generate types --help + $ openapi-helpers generate errors --help +` +); + +program.parse(); diff --git a/src/common/constants.ts b/src/common/constants.ts new file mode 100644 index 0000000..36d6135 --- /dev/null +++ b/src/common/constants.ts @@ -0,0 +1,5 @@ +import { readPackageJsonSync } from '@map-colonies/read-pkg'; + +const packageJson = readPackageJsonSync(); +const PACKAGE_VERSION = packageJson.version ?? 'unknown'; +export { PACKAGE_VERSION }; diff --git a/src/generator/generateErrors.mts b/src/generator/generateErrors.mts index 34a9b71..0ee0efe 100644 --- a/src/generator/generateErrors.mts +++ b/src/generator/generateErrors.mts @@ -1,74 +1,13 @@ import fs from 'node:fs/promises'; -import { parseArgs } from 'node:util'; import path from 'node:path'; import { dereference } from '@apidevtools/json-schema-ref-parser'; import { format, resolveConfig } from 'prettier'; import * as changeCase from 'change-case'; import type { OpenAPI3, OperationObject, ResponseObject, SchemaObject } from 'openapi-typescript'; -const ARGS_SLICE = 2; - -const { - values: { format: shouldFormat }, - positionals, -} = parseArgs({ - args: process.argv.slice(ARGS_SLICE), - options: { - format: { type: 'boolean', alias: 'f' }, - }, - allowPositionals: true, -}); - -const [openapiPath, destinationPath] = positionals; - -if (openapiPath === undefined || destinationPath === undefined) { - console.error('Usage: generateErrors '); - process.exit(1); -} - -const openapi = await dereference(openapiPath); - -if (openapi.paths === undefined) { - console.error('No paths found in the OpenAPI document.'); - process.exit(1); -} - -const errorCodes = new Set(); - -function extractCodeFromSchema(schema: SchemaObject): void { - // Handle direct code property - if (schema.type === 'object' && schema.properties?.code) { - const codeProperty = schema.properties.code as SchemaObject; - - // Handle enum values - if (codeProperty.enum) { - codeProperty.enum.map(String).forEach((code) => { - errorCodes.add(code); - }); - } - } - - // Handle allOf combinations - if (schema.allOf) { - for (const subSchema of schema.allOf) { - extractCodeFromSchema(subSchema as SchemaObject); - } - } - - // Handle oneOf combinations - if (schema.oneOf) { - for (const subSchema of schema.oneOf) { - extractCodeFromSchema(subSchema as SchemaObject); - } - } - - // Handle anyOf combinations - if (schema.anyOf) { - for (const subSchema of schema.anyOf) { - extractCodeFromSchema(subSchema as SchemaObject); - } - } -} +const ESLINT_DISABLE = '/* eslint-disable */\n'; +const FILE_HEADER = `${ESLINT_DISABLE}// This file was auto-generated. Do not edit manually. +// To update, run the error generation script again.\n\n`; function createError(code: string): string { let className = changeCase.pascalCase(code); @@ -91,39 +30,105 @@ function createError(code: string): string { };\n`; } -for (const [, methods] of Object.entries(openapi.paths)) { - for (const [key, operation] of Object.entries(methods) as [string, OperationObject][]) { - if (['servers', 'parameters'].includes(key)) { - continue; +function buildErrorMapping(errorCodes: Set): string { + return Array.from(errorCodes) + .map((code) => `'${code}': '${code}'`) + .join(', '); +} + +export async function generateErrors( + openapiPath: string, + destinationPath: string, + options: { + shouldFormat?: boolean; + includeMapping?: boolean; + includeErrorClasses?: boolean; + } +): Promise { + const openapi = await dereference(openapiPath); + + if (openapi.paths === undefined) { + console.error('No paths found in the OpenAPI document.'); + process.exit(1); + } + + const errorCodes = new Set(); + + function extractCodeFromSchema(schema: SchemaObject): void { + // Handle direct code property + if (schema.type === 'object' && schema.properties?.code) { + const codeProperty = schema.properties.code as SchemaObject; + + // Handle enum values + if (codeProperty.enum) { + codeProperty.enum.map(String).forEach((code) => { + errorCodes.add(code); + }); + } } - for (const [statusCode, response] of Object.entries(operation.responses ?? {}) as [string, ResponseObject][]) { - if (statusCode.startsWith('2') || statusCode.startsWith('3')) { - continue; // Skip successful and redirection responses + // Handle allOf combinations + if (schema.allOf) { + for (const subSchema of schema.allOf) { + extractCodeFromSchema(subSchema as SchemaObject); } + } + + // Handle oneOf combinations + if (schema.oneOf) { + for (const subSchema of schema.oneOf) { + extractCodeFromSchema(subSchema as SchemaObject); + } + } - const schema = response.content?.['application/json']?.schema as SchemaObject | undefined; - if (schema) { - extractCodeFromSchema(schema); + // Handle anyOf combinations + if (schema.anyOf) { + for (const subSchema of schema.anyOf) { + extractCodeFromSchema(subSchema as SchemaObject); } } } -} -if (errorCodes.size === 0) { - console.warn('No error codes found in the OpenAPI document.'); - process.exit(0); -} + for (const [, methods] of Object.entries(openapi.paths)) { + for (const [key, operation] of Object.entries(methods) as [string, OperationObject][]) { + if (['servers', 'parameters'].includes(key)) { + continue; + } -let errorFile = errorCodes.values().map(createError).toArray().join('\n'); + for (const [statusCode, response] of Object.entries(operation.responses ?? {}) as [string, ResponseObject][]) { + if (statusCode.startsWith('2') || statusCode.startsWith('3')) { + continue; // Skip successful and redirection responses + } -if (shouldFormat === true) { - const prettierOptions = await resolveConfig('./src/index.ts'); + const schema = response.content?.['application/json']?.schema as SchemaObject | undefined; + if (schema) { + extractCodeFromSchema(schema); + } + } + } + } - errorFile = await format(errorFile, { ...prettierOptions, parser: 'typescript' }); -} + if (errorCodes.size === 0) { + console.warn('No error codes found in the OpenAPI document.'); + process.exit(0); + } + let errorFile = FILE_HEADER; -const directory = path.dirname(destinationPath); -await fs.mkdir(directory, { recursive: true }); + if (options.includeErrorClasses === true) { + errorFile += errorCodes.values().map(createError).toArray().join('\n'); + } -await fs.writeFile(destinationPath, errorFile); + if (options.includeMapping === true) { + errorFile += ` export const API_ERRORS_MAP = { ${buildErrorMapping(errorCodes)} } as const;\n`; + } + + if (options.shouldFormat === true) { + const prettierOptions = await resolveConfig('./src/index.ts'); + errorFile = await format(errorFile, { ...prettierOptions, parser: 'typescript' }); + } + + const directory = path.dirname(destinationPath); + await fs.mkdir(directory, { recursive: true }); + + await fs.writeFile(destinationPath, errorFile); +} diff --git a/src/generator/generateTypes.mts b/src/generator/generateTypes.mts index 3fc1579..367abd4 100644 --- a/src/generator/generateTypes.mts +++ b/src/generator/generateTypes.mts @@ -1,52 +1,42 @@ #!/usr/bin/env node import fs from 'node:fs/promises'; -import { parseArgs } from 'node:util'; import { format, resolveConfig } from 'prettier'; -import openapiTS, { astToString } from 'openapi-typescript'; - -const ARGS_SLICE = 2; - -const { - values: { format: shouldFormat, 'add-typed-request-handler': addTypedRequestHandler }, - positionals, -} = parseArgs({ - args: process.argv.slice(ARGS_SLICE), - options: { - format: { type: 'boolean', alias: 'f' }, - 'add-typed-request-handler': { type: 'boolean', alias: 't' }, - }, - allowPositionals: true, -}); - -const [openapiPath, destinationPath] = positionals; - -if (openapiPath === undefined || destinationPath === undefined) { - console.error('Usage: generateTypes '); - process.exit(1); -} +import openapiTS, { astToString, SchemaObject, TransformNodeOptions, TransformObject } from 'openapi-typescript'; +import { TypeNode } from 'typescript'; const ESLINT_DISABLE = '/* eslint-disable */\n'; +const FILE_HEADER = `${ESLINT_DISABLE}// This file was auto-generated. Do not edit manually. +// To update, run the error generation script again.\n\n`; const typedRequestHandlerImport = "import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-helpers/typedRequestHandler';\n"; const exportTypedRequestHandlers = 'export type TypedRequestHandlers = ImportedTypedRequestHandlers;\n'; -const ast = await openapiTS(await fs.readFile(openapiPath, 'utf-8'), { exportType: true }); +export async function generateTypes( + openapiPath: string, + destinationPath: string, + options: { + shouldFormat?: boolean; + addTypedRequestHandler?: boolean; + inject?: string; + transform?: (schemaObject: SchemaObject, metadata: TransformNodeOptions) => TypeNode | TransformObject | undefined; + } +): Promise { + const ast = await openapiTS(await fs.readFile(openapiPath, 'utf-8'), { exportType: true, inject: options.inject, transform: options.transform }); -let content = astToString(ast); + let content = astToString(ast); -if (addTypedRequestHandler === true) { - content = typedRequestHandlerImport + content + exportTypedRequestHandlers; -} + if (options.addTypedRequestHandler === true) { + content = typedRequestHandlerImport + content + exportTypedRequestHandlers; + } -content = ESLINT_DISABLE + content; + content = FILE_HEADER + content; -if (shouldFormat === true) { - const prettierOptions = await resolveConfig('./src/index.ts'); + if (options.shouldFormat === true) { + const prettierOptions = await resolveConfig('./src/index.ts'); - content = await format(content, { ...prettierOptions, parser: 'typescript' }); -} + content = await format(content, { ...prettierOptions, parser: 'typescript' }); + } -await fs.writeFile(destinationPath, content); - -console.log('Types generated successfully'); + await fs.writeFile(destinationPath, content); +} diff --git a/src/generator/index.mts b/src/generator/index.mts new file mode 100644 index 0000000..071382e --- /dev/null +++ b/src/generator/index.mts @@ -0,0 +1,2 @@ +export * from './generateTypes.mjs'; +export * from './generateErrors.mjs'; diff --git a/tests/test-with-errors.yaml b/tests/test-with-errors.yaml new file mode 100644 index 0000000..bab5c66 --- /dev/null +++ b/tests/test-with-errors.yaml @@ -0,0 +1,59 @@ +openapi: 3.0.0 +info: + title: Test API with Errors + version: 1.0.0 +paths: + /test: + get: + operationId: testOperation + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + code: + type: string + enum: + - INVALID_INPUT + - MISSING_PARAMETER + message: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + code: + type: string + enum: + - RESOURCE_NOT_FOUND + message: + type: string + '500': + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + code: + type: string + enum: + - INTERNAL_ERROR + - DATABASE_ERROR + message: + type: string