diff --git a/apps/demo/project.json b/apps/demo/project.json index f03474b..34085d8 100644 --- a/apps/demo/project.json +++ b/apps/demo/project.json @@ -30,10 +30,11 @@ "generate-api-with-core": { "executor": "@nx-plugin-openapi/core:generate-api", "options": { + "generator": "hey-openapi", "inputSpec": "apps/demo/swagger.json", "outputPath": "apps/demo/src/app/api-core", "generatorOptions": { - "skipValidateSpec": true + "client": "fetch" } }, "outputs": ["{options.outputPath}"] diff --git a/package-lock.json b/package-lock.json index 359c533..1fd1304 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@angular/language-service": "~18.2.0", "@astrojs/starlight": "^0.34.4", "@eslint/js": "^9.8.0", + "@hey-api/openapi-ts": "^0.83.1", "@nx/angular": "19.8.14", "@nx/eslint": "19.8.14", "@nx/eslint-plugin": "19.8.14", @@ -3726,6 +3727,113 @@ "@expressive-code/core": "^0.41.3" } }, + "node_modules/@hey-api/codegen-core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.1.0.tgz", + "integrity": "sha512-7B6B9Zuw4uQWoG5YwAwNC+UTz6ErAujqaQrJuMu/OgPkUHq+hl6zdKKi2MX/399vPYilo0IGBxQHmY1UfmTHsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.1.0.tgz", + "integrity": "sha512-+5eg9pgAAM9oSqJQuUtfTKbLz8yieFKN91myyXiLnprqFj8ROfxUKJLr9DKq/hGKyybKT1WxFSetDqCFm80pCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.83.1.tgz", + "integrity": "sha512-AJIXDsXBLXS4uzhU+yVeNmD43uN6FYPcWpcid7Q0bA4JRXpI+6livOzUUMiuszt3NlgHy1gcf6x15W2xHNSoag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "^0.1.0", + "@hey-api/json-schema-ref-parser": "1.1.0", + "ansi-colors": "4.1.3", + "c12": "2.0.1", + "color-support": "1.1.3", + "commander": "13.0.0", + "handlebars": "4.7.8", + "open": "10.1.2", + "semver": "7.7.2" + }, + "bin": { + "openapi-ts": "bin/index.cjs" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/openapi-ts/node_modules/commander": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@hey-api/openapi-ts/node_modules/open": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@hey-api/openapi-ts/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4966,6 +5074,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -12393,6 +12508,82 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", + "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.1", + "confbox": "^0.1.7", + "defu": "^6.1.4", + "dotenv": "^16.4.5", + "giget": "^1.2.3", + "jiti": "^2.3.0", + "mlly": "^1.7.1", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "perfect-debounce": "^1.0.0", + "pkg-types": "^1.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/c12/node_modules/ohash": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", + "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/c12/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cacache": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", @@ -12733,6 +12924,16 @@ "node": ">=8" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -12998,6 +13199,16 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -13176,6 +13387,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -16361,6 +16579,32 @@ "assert-plus": "^1.0.0" } }, + "node_modules/giget": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", + "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.5.4", + "pathe": "^2.0.3", + "tar": "^6.2.1" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/giget/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -21937,6 +22181,26 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -22748,6 +23012,34 @@ "node": ">=8" } }, + "node_modules/nypm": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", + "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "tinyexec": "^0.3.2", + "ufo": "^1.5.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/nypm/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -23493,6 +23785,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/peek-readable": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", @@ -23517,6 +23816,13 @@ "through2": "^2.0.3" } }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -23663,6 +23969,25 @@ "node": ">=8" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pkginfo": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.4.1.tgz", @@ -24798,6 +25123,17 @@ "node": ">=0.10.0" } }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", diff --git a/package.json b/package.json index 17d6161..999812c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@angular/language-service": "~18.2.0", "@astrojs/starlight": "^0.34.4", "@eslint/js": "^9.8.0", + "@hey-api/openapi-ts": "^0.83.1", "@nx/angular": "19.8.14", "@nx/eslint": "19.8.14", "@nx/eslint-plugin": "19.8.14", diff --git a/packages/core/src/lib/plugin-loader.ts b/packages/core/src/lib/plugin-loader.ts index 1e1164f..6c81298 100644 --- a/packages/core/src/lib/plugin-loader.ts +++ b/packages/core/src/lib/plugin-loader.ts @@ -6,6 +6,7 @@ import { logger } from '@nx/devkit'; const BUILTIN_PLUGIN_MAP: Record = { 'openapi-tools': '@nx-plugin-openapi/plugin-openapi', + 'hey-openapi': '@nx-plugin-openapi/plugin-hey-openapi', }; const cache = new Map(); @@ -15,13 +16,13 @@ export async function loadPlugin( opts?: { root?: string } ): Promise { logger.debug(`Loading plugin: ${name}`); - + // Check registry first if (GeneratorRegistry.instance().has(name)) { logger.debug(`Plugin ${name} found in registry`); return GeneratorRegistry.instance().get(name); } - + // Check cache const cached = cache.get(name); if (cached) { @@ -31,9 +32,9 @@ export async function loadPlugin( const pkg = BUILTIN_PLUGIN_MAP[name] ?? name; const searchPaths: string[] = [pkg]; - + logger.debug(`Attempting to load plugin from package: ${pkg}`); - + try { const mod = (await import(pkg)) as { default?: unknown; @@ -45,7 +46,7 @@ export async function loadPlugin( logger.debug(`Successfully imported module: ${pkg}`); let candidate: unknown = undefined; - + // Try different export patterns if (isGeneratorPlugin(mod.default)) { logger.debug(`Found plugin as default export`); @@ -62,10 +63,16 @@ export async function loadPlugin( } if (!isGeneratorPlugin(candidate)) { - const availableExports = Object.keys(mod).filter(k => k !== '__esModule'); + const availableExports = Object.keys(mod).filter( + (k) => k !== '__esModule' + ); throw new PluginLoadError( name, - new Error(`Module does not export a valid plugin. Available exports: ${availableExports.join(', ')}`) + new Error( + `Module does not export a valid plugin. Available exports: ${availableExports.join( + ', ' + )}` + ) ); } @@ -74,25 +81,30 @@ export async function loadPlugin( return candidate; } catch (e) { logger.debug(`Failed to load plugin from primary path: ${e}`); - + // Fallback to workspace-relative resolution if provided const root = opts?.root ?? process.cwd(); - - if (pkg === '@nx-plugin-openapi/plugin-openapi') { + + if ( + pkg === '@nx-plugin-openapi/plugin-openapi' || + pkg === '@nx-plugin-openapi/plugin-hey-openapi' + ) { + const pkgName = pkg.split('/').pop()!; // e.g., 'plugin-openapi' or 'plugin-hey-openapi' const fallbackPaths = [ - `${root}/dist/packages/plugin-openapi/src/index.js`, - `${root}/packages/plugin-openapi/src/index.js` + `${root}/dist/packages/${pkgName}/src/index.js`, + `${root}/packages/${pkgName}/src/index.js`, ]; searchPaths.push(...fallbackPaths); - + logger.debug(`Trying fallback paths for built-in plugin`); - + for (const p of fallbackPaths) { try { logger.debug(`Attempting to load from: ${p}`); - const url = `file://${p}`; + const { pathToFileURL } = await import('node:url'); + const url = pathToFileURL(p).href; const mod2 = (await import(url)) as { default?: unknown }; - + if (isGeneratorPlugin(mod2?.default)) { const plugin2 = mod2.default; logger.info(`Successfully loaded plugin from fallback path: ${p}`); @@ -108,12 +120,16 @@ export async function loadPlugin( // Determine appropriate error type const msg = String(e); const code = (e as Record)?.['code']; - + if (code === 'ERR_MODULE_NOT_FOUND' || /Cannot find module/.test(msg)) { - logger.error(`Plugin not found: ${name}. Searched paths: ${JSON.stringify(searchPaths)}`); + logger.error( + `Plugin not found: ${name}. Searched paths: ${JSON.stringify( + searchPaths + )}` + ); throw new PluginNotFoundError(name, searchPaths); } - + logger.error(`Failed to load plugin: ${name}. Error: ${e}`); throw new PluginLoadError(name, e); } diff --git a/packages/plugin-hey-openapi/README.md b/packages/plugin-hey-openapi/README.md new file mode 100644 index 0000000..35a66e3 --- /dev/null +++ b/packages/plugin-hey-openapi/README.md @@ -0,0 +1,11 @@ +# plugin-hey-openapi + +This library provides a generator plugin that uses `@hey-api/openapi-ts` to generate TypeScript clients from OpenAPI specs. + +## Building + +Run `nx build plugin-hey-openapi` to build the library. + +## Running unit tests + +Run `nx test plugin-hey-openapi` to execute the unit tests via Jest. diff --git a/packages/plugin-hey-openapi/eslint.config.js b/packages/plugin-hey-openapi/eslint.config.js new file mode 100644 index 0000000..9d2af7a --- /dev/null +++ b/packages/plugin-hey-openapi/eslint.config.js @@ -0,0 +1,19 @@ +const baseConfig = require('../../eslint.config.js'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'], + }, + ], + }, + languageOptions: { + parser: require('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/plugin-hey-openapi/jest.config.ts b/packages/plugin-hey-openapi/jest.config.ts new file mode 100644 index 0000000..13248a6 --- /dev/null +++ b/packages/plugin-hey-openapi/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'plugin-hey-openapi', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/plugin-hey-openapi', +}; diff --git a/packages/plugin-hey-openapi/package.json b/packages/plugin-hey-openapi/package.json new file mode 100644 index 0000000..5d68fe6 --- /dev/null +++ b/packages/plugin-hey-openapi/package.json @@ -0,0 +1,15 @@ +{ + "name": "@nx-plugin-openapi/plugin-hey-openapi", + "version": "0.0.1", + "dependencies": { + "@nx-plugin-openapi/core": "0.0.1", + "tslib": "^2.3.0", + "@nx/devkit": "19.8.14" + }, + "peerDependencies": { + "@nx-plugin-openapi/core": ">=0.0.1" + }, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts" +} diff --git a/packages/plugin-hey-openapi/project.json b/packages/plugin-hey-openapi/project.json new file mode 100644 index 0000000..3b43fed --- /dev/null +++ b/packages/plugin-hey-openapi/project.json @@ -0,0 +1,42 @@ +{ + "name": "plugin-hey-openapi", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugin-hey-openapi/src", + "projectType": "library", + "release": { + "version": { + "generatorOptions": { + "packageRoot": "dist/{projectRoot}", + "currentVersionResolver": "git-tag" + } + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/plugin-hey-openapi", + "main": "packages/plugin-hey-openapi/src/index.ts", + "tsConfig": "packages/plugin-hey-openapi/tsconfig.lib.json", + "assets": ["packages/plugin-hey-openapi/*.md"] + } + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/plugin-hey-openapi/jest.config.ts" + } + } + } +} diff --git a/packages/plugin-hey-openapi/src/index.ts b/packages/plugin-hey-openapi/src/index.ts new file mode 100644 index 0000000..ddc8c87 --- /dev/null +++ b/packages/plugin-hey-openapi/src/index.ts @@ -0,0 +1,3 @@ +export { default as HeyOpenApiPlugin } from './lib/hey-openapi-generator'; +export { default } from './lib/hey-openapi-generator'; +export { HeyOpenApiGenerator } from './lib/hey-openapi-generator'; diff --git a/packages/plugin-hey-openapi/src/lib/hey-openapi-generator.spec.ts b/packages/plugin-hey-openapi/src/lib/hey-openapi-generator.spec.ts new file mode 100644 index 0000000..1308764 --- /dev/null +++ b/packages/plugin-hey-openapi/src/lib/hey-openapi-generator.spec.ts @@ -0,0 +1,86 @@ +import { HeyOpenApiGenerator } from './hey-openapi-generator'; +import { GeneratorContext } from '@nx-plugin-openapi/core'; + +jest.mock( + '@hey-api/openapi-ts', + () => ({ + generate: jest.fn(async () => undefined), + }), + { virtual: true } +); + +describe('HeyOpenApiGenerator', () => { + let generator: HeyOpenApiGenerator; + let mockContext: GeneratorContext; + let cleanOutputSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + generator = new HeyOpenApiGenerator(); + mockContext = { root: '/workspace', workspaceName: 'test' }; + // Spy on cleanOutput inherited from BaseGenerator + cleanOutputSpy = jest + .spyOn( + generator as unknown as { cleanOutput: (...a: unknown[]) => void }, + 'cleanOutput' + ) + .mockImplementation(() => {}); + }); + + it('should have correct plugin name', () => { + expect(generator.name).toBe('hey-openapi'); + }); + + it('should call openapi-ts generate for single spec', async () => { + const mod = (await import('@hey-api/openapi-ts')) as unknown as { + generate: jest.Mock; + }; + + await generator.generate( + { + inputSpec: 'api.yaml', + outputPath: 'src/generated', + generatorOptions: { client: 'fetch' }, + } as unknown as Parameters[0], + mockContext + ); + + expect(cleanOutputSpy).toHaveBeenCalledWith(mockContext, 'src/generated'); + expect(mod.generate).toHaveBeenCalledWith( + expect.objectContaining({ + input: 'api.yaml', + output: '/workspace/src/generated', + client: 'fetch', + }) + ); + }); + + it('should call openapi-ts generate for multiple specs', async () => { + const mod = (await import('@hey-api/openapi-ts')) as unknown as { + generate: jest.Mock; + }; + + await generator.generate( + { + inputSpec: { users: 'users.yaml', products: 'products.yaml' }, + outputPath: 'src/api', + } as unknown as Parameters[0], + mockContext + ); + + expect(cleanOutputSpy).toHaveBeenCalledTimes(2); + expect(mod.generate).toHaveBeenCalledTimes(2); + expect(mod.generate).toHaveBeenCalledWith( + expect.objectContaining({ + input: 'users.yaml', + output: '/workspace/src/api/users', + }) + ); + expect(mod.generate).toHaveBeenCalledWith( + expect.objectContaining({ + input: 'products.yaml', + output: '/workspace/src/api/products', + }) + ); + }); +}); diff --git a/packages/plugin-hey-openapi/src/lib/hey-openapi-generator.ts b/packages/plugin-hey-openapi/src/lib/hey-openapi-generator.ts new file mode 100644 index 0000000..04ff3d0 --- /dev/null +++ b/packages/plugin-hey-openapi/src/lib/hey-openapi-generator.ts @@ -0,0 +1,100 @@ +import { join } from 'node:path'; +import { logger } from '@nx/devkit'; +import { + BaseGenerator, + GeneratorContext, + GeneratorPlugin, + GenerateOptionsBase, +} from '@nx-plugin-openapi/core'; + +export interface HeyOpenApiOptions { + [key: string]: unknown; +} + +export class HeyOpenApiGenerator + extends BaseGenerator + implements GeneratorPlugin +{ + readonly name = 'hey-openapi'; + + async generate( + options: HeyOpenApiOptions & GenerateOptionsBase, + ctx: GeneratorContext + ): Promise { + const { inputSpec, outputPath } = options; + const generatorOptions = (options.generatorOptions || + {}) as Partial; + + logger.info(`Starting hey-openapi code generation`); + logger.debug(`Input spec: ${JSON.stringify(inputSpec)}`); + logger.debug(`Output path: ${outputPath}`); + + if (typeof inputSpec === 'string') { + this.cleanOutput(ctx, outputPath); + await this.invokeOpenApiTs({ + input: inputSpec, + output: join(ctx.root, outputPath), + ...generatorOptions, + }); + } else { + const entries = Object.entries(inputSpec as Record) as [ + string, + string + ][]; + + logger.info(`Generating code for ${entries.length} services`); + + for (const [serviceName, specPath] of entries) { + logger.info(`Generating service: ${serviceName}`); + const serviceOutputPath = join(outputPath, serviceName); + this.cleanOutput(ctx, serviceOutputPath); + await this.invokeOpenApiTs({ + input: specPath, + output: join(ctx.root, serviceOutputPath), + ...generatorOptions, + }); + } + } + + logger.info(`hey-openapi code generation completed successfully`); + } + + private async invokeOpenApiTs( + config: { input: string; output: string } & Record + ): Promise { + let mod: Record; + try { + mod = (await import('@hey-api/openapi-ts')) as Record; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error( + `@hey-api/openapi-ts is required but not installed. Install it in your workspace devDependencies. Original error: ${msg}` + ); + } + + const generateExport = (mod as Record)['generate']; + const createClientExport = (mod as Record)['createClient']; + + const fn = + typeof generateExport === 'function' + ? (generateExport as (cfg: Record) => Promise) + : typeof createClientExport === 'function' + ? (createClientExport as ( + cfg: Record + ) => Promise) + : undefined; + + if (!fn) { + const keys = Object.keys(mod).filter((k) => k !== '__esModule'); + throw new Error( + `@hey-api/openapi-ts does not export a supported API. Expected 'generate' or 'createClient'. Available: ${keys.join( + ', ' + )}` + ); + } + + await fn(config as Record); + } +} + +export default new HeyOpenApiGenerator(); diff --git a/packages/plugin-hey-openapi/src/types/openapi-ts.d.ts b/packages/plugin-hey-openapi/src/types/openapi-ts.d.ts new file mode 100644 index 0000000..907ab56 --- /dev/null +++ b/packages/plugin-hey-openapi/src/types/openapi-ts.d.ts @@ -0,0 +1 @@ +declare module '@hey-api/openapi-ts'; diff --git a/packages/plugin-hey-openapi/tsconfig.json b/packages/plugin-hey-openapi/tsconfig.json new file mode 100644 index 0000000..f5b8565 --- /dev/null +++ b/packages/plugin-hey-openapi/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/plugin-hey-openapi/tsconfig.lib.json b/packages/plugin-hey-openapi/tsconfig.lib.json new file mode 100644 index 0000000..33eca2c --- /dev/null +++ b/packages/plugin-hey-openapi/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/plugin-hey-openapi/tsconfig.spec.json b/packages/plugin-hey-openapi/tsconfig.spec.json new file mode 100644 index 0000000..9b2a121 --- /dev/null +++ b/packages/plugin-hey-openapi/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}