From b49c290651d2ba0b59fe4a22c9f6c73b5580ee81 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:11:49 +0700 Subject: [PATCH 01/13] chore: add @inquirer/prompts dependency --- bun.lock | 70 +++++++++++++++++++++++++++++++++++++++++++++++++--- package.json | 18 ++++++++++---- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index cc0fd6e..aa2b26e 100644 --- a/bun.lock +++ b/bun.lock @@ -4,16 +4,26 @@ "": { "name": "apigen", "dependencies": { - "@redocly/openapi-core": "^1.0.0", - "commander": "^13.0.0", - "swagger2openapi": "^7.0.0", - "yaml": "^2.0.0", + "@inquirer/prompts": "^8.3.0", }, "devDependencies": { + "@faker-js/faker": "^10.3.0", + "@redocly/openapi-core": "^1.0.0", "@types/node": "^22.0.0", + "commander": "^13.0.0", + "swagger2openapi": "^7.0.0", "typescript": "^5.9.0", "vitest": "^3.0.0", + "yaml": "^2.0.0", + }, + "peerDependencies": { + "@tanstack/react-query": "^5", + "react": "^18 || ^19", }, + "optionalPeers": [ + "@tanstack/react-query", + "react", + ], }, }, "packages": { @@ -71,6 +81,40 @@ "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], + "@faker-js/faker": ["@faker-js/faker@10.3.0", "", {}, "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw=="], + + "@inquirer/ansi": ["@inquirer/ansi@2.0.3", "", {}, "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/core": "^11.1.5", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.0.8", "", { "dependencies": { "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw=="], + + "@inquirer/core": ["@inquirer/core@11.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A=="], + + "@inquirer/editor": ["@inquirer/editor@5.0.8", "", { "dependencies": { "@inquirer/core": "^11.1.5", "@inquirer/external-editor": "^2.0.3", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA=="], + + "@inquirer/expand": ["@inquirer/expand@5.0.8", "", { "dependencies": { "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@2.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.3", "", {}, "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g=="], + + "@inquirer/input": ["@inquirer/input@5.0.8", "", { "dependencies": { "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw=="], + + "@inquirer/number": ["@inquirer/number@4.0.8", "", { "dependencies": { "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ=="], + + "@inquirer/password": ["@inquirer/password@5.0.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.3.0", "", { "dependencies": { "@inquirer/checkbox": "^5.1.0", "@inquirer/confirm": "^6.0.8", "@inquirer/editor": "^5.0.8", "@inquirer/expand": "^5.0.8", "@inquirer/input": "^5.0.8", "@inquirer/number": "^4.0.8", "@inquirer/password": "^5.0.8", "@inquirer/rawlist": "^5.2.4", "@inquirer/search": "^4.1.4", "@inquirer/select": "^5.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.4", "", { "dependencies": { "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg=="], + + "@inquirer/search": ["@inquirer/search@4.1.4", "", { "dependencies": { "@inquirer/core": "^11.1.5", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ=="], + + "@inquirer/select": ["@inquirer/select@5.1.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/core": "^11.1.5", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q=="], + + "@inquirer/type": ["@inquirer/type@4.0.3", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], @@ -171,8 +215,12 @@ "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "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=="], @@ -205,6 +253,12 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -215,6 +269,8 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], @@ -233,6 +289,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "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=="], @@ -271,6 +329,8 @@ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "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=="], @@ -285,6 +345,8 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], diff --git a/package.json b/package.json index bf4ef2c..38f5b73 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,13 @@ "prepublishOnly": "bun run typecheck && bun test && bun run build" }, "devDependencies": { - "typescript": "^5.9.0", - "vitest": "^3.0.0", + "@faker-js/faker": "^10.3.0", + "@redocly/openapi-core": "^1.0.0", "@types/node": "^22.0.0", "commander": "^13.0.0", - "@redocly/openapi-core": "^1.0.0", "swagger2openapi": "^7.0.0", + "typescript": "^5.9.0", + "vitest": "^3.0.0", "yaml": "^2.0.0" }, "peerDependencies": { @@ -52,10 +53,17 @@ "@tanstack/react-query": "^5" }, "peerDependenciesMeta": { - "react": { "optional": true }, - "@tanstack/react-query": { "optional": true } + "react": { + "optional": true + }, + "@tanstack/react-query": { + "optional": true + } }, "engines": { "node": ">=18" + }, + "dependencies": { + "@inquirer/prompts": "^8.3.0" } } From 0b8b8840a2c2426459eb50278807b58fff2075e8 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:13:36 +0700 Subject: [PATCH 02/13] feat: add auto-discovery of API specs at well-known paths --- src/discover.ts | 53 ++++++++++++++++++++ tests/discover.test.ts | 111 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/discover.ts create mode 100644 tests/discover.test.ts diff --git a/src/discover.ts b/src/discover.ts new file mode 100644 index 0000000..7fa8088 --- /dev/null +++ b/src/discover.ts @@ -0,0 +1,53 @@ +import { parse as parseYaml } from 'yaml' +import { detectSpecVersion } from './loader' +import type { SpecVersion } from './loader' + +const WELL_KNOWN_PATHS = [ + '/v3/api-docs', + '/swagger.json', + '/openapi.json', + '/api-docs', + '/docs/openapi.json', +] as const + +interface DiscoverResult { + url: string + version: SpecVersion +} + +async function discoverSpec(baseUrl: string): Promise { + const normalizedBase = baseUrl.replace(/\/+$/, '') + const tried: string[] = [] + + for (const path of WELL_KNOWN_PATHS) { + const url = `${normalizedBase}${path}` + tried.push(url) + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(3000) }) + if (!response.ok) continue + + const text = await response.text() + let parsed: Record + try { + parsed = JSON.parse(text) + } catch { + parsed = parseYaml(text) as Record + } + + const version = detectSpecVersion(parsed) + if (version !== 'unknown') { + return { url, version } + } + } catch { + continue + } + } + + throw new Error( + `Could not find an API spec at ${normalizedBase}. Tried:\n${tried.map((u) => ` - ${u}`).join('\n')}` + ) +} + +export { discoverSpec, WELL_KNOWN_PATHS } +export type { DiscoverResult } diff --git a/tests/discover.test.ts b/tests/discover.test.ts new file mode 100644 index 0000000..308cde6 --- /dev/null +++ b/tests/discover.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, afterAll, beforeAll } from 'vitest' +import { createServer, type Server } from 'http' +import { discoverSpec, WELL_KNOWN_PATHS } from '../src/discover' + +describe('discoverSpec', () => { + let server: Server + let baseUrl: string + + beforeAll(async () => { + server = createServer((req, res) => { + if (req.url === '/v3/api-docs') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ openapi: '3.0.3', info: { title: 'Test', version: '1.0' }, paths: {} })) + } else if (req.url === '/swagger.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ swagger: '2.0', info: { title: 'Test', version: '1.0' }, paths: {} })) + } else { + res.writeHead(404) + res.end('Not Found') + } + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr && typeof addr === 'object') { + baseUrl = `http://127.0.0.1:${addr.port}` + } + resolve() + }) + }) + }) + + afterAll(() => { + server?.close() + }) + + it('discovers OpenAPI 3.x spec at first matching path', async () => { + const result = await discoverSpec(baseUrl) + expect(result.url).toBe(`${baseUrl}/v3/api-docs`) + expect(result.version).toBe('openapi3') + }) + + it('discovers Swagger 2.0 spec when only swagger.json is available', async () => { + // Create a server that only serves /swagger.json + const swaggerServer = createServer((req, res) => { + if (req.url === '/swagger.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ swagger: '2.0', info: { title: 'Test', version: '1.0' }, paths: {} })) + } else { + res.writeHead(404) + res.end('Not Found') + } + }) + + const swaggerBaseUrl = await new Promise((resolve) => { + swaggerServer.listen(0, '127.0.0.1', () => { + const addr = swaggerServer.address() + if (addr && typeof addr === 'object') { + resolve(`http://127.0.0.1:${addr.port}`) + } + }) + }) + + try { + const result = await discoverSpec(swaggerBaseUrl) + expect(result.url).toBe(`${swaggerBaseUrl}/swagger.json`) + expect(result.version).toBe('swagger2') + } finally { + swaggerServer.close() + } + }) + + it('throws when no spec is found at any well-known path', async () => { + // Create a server that returns 404 for everything + const emptyServer = createServer((_req, res) => { + res.writeHead(404) + res.end('Not Found') + }) + + const emptyBaseUrl = await new Promise((resolve) => { + emptyServer.listen(0, '127.0.0.1', () => { + const addr = emptyServer.address() + if (addr && typeof addr === 'object') { + resolve(`http://127.0.0.1:${addr.port}`) + } + }) + }) + + try { + await expect(discoverSpec(emptyBaseUrl)).rejects.toThrow('Could not find an API spec') + } finally { + emptyServer.close() + } + }) + + it('strips trailing slash from base URL', async () => { + const result = await discoverSpec(`${baseUrl}/`) + expect(result.url).toBe(`${baseUrl}/v3/api-docs`) + }) + + it('exports the well-known paths list', () => { + expect(WELL_KNOWN_PATHS).toEqual([ + '/v3/api-docs', + '/swagger.json', + '/openapi.json', + '/api-docs', + '/docs/openapi.json', + ]) + }) +}) From 07549e9c52ac6bf3cfd291eff16a6c6bc2546092 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:14:30 +0700 Subject: [PATCH 03/13] feat: add interactive spec source prompt when -i is omitted --- src/cli.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 05ed601..4ea73d9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,9 +2,51 @@ import { Command } from 'commander' import { resolve } from 'path' +import { select, input } from '@inquirer/prompts' import { loadSpec } from './loader' import { extractIR } from './ir' import { writeGeneratedFiles } from './writer' +import { discoverSpec } from './discover' + +async function promptForInput(): Promise { + const source = await select({ + message: 'How would you like to provide your API spec?', + choices: [ + { name: 'Local file path', value: 'file' }, + { name: 'Direct URL to spec', value: 'url' }, + { name: 'Auto-discover from base URL', value: 'discover' }, + ], + }) + + if (source === 'file') { + const filePath = await input({ + message: 'Enter the file path:', + validate: (v) => (v.trim().length > 0 ? true : 'File path is required'), + }) + return resolve(filePath.trim()) + } + + if (source === 'url') { + const url = await input({ + message: 'Enter the spec URL:', + validate: (v) => + v.startsWith('http://') || v.startsWith('https://') ? true : 'Must be an http:// or https:// URL', + }) + return url.trim() + } + + // source === 'discover' + const baseUrl = await input({ + message: 'Enter your API base URL (e.g. http://localhost:8080):', + validate: (v) => + v.startsWith('http://') || v.startsWith('https://') ? true : 'Must be an http:// or https:// URL', + }) + + console.log('Searching for API spec...') + const result = await discoverSpec(baseUrl.trim()) + console.log(`Found ${result.version === 'swagger2' ? 'Swagger 2.0' : 'OpenAPI 3.x'} spec at ${result.url}`) + return result.url +} const program = new Command() @@ -16,11 +58,14 @@ program program .command('generate') .description('Generate hooks, types, and mocks from an OpenAPI spec') - .requiredOption('-i, --input ', 'Path to OpenAPI/Swagger spec file') + .option('-i, --input ', 'Path or URL to OpenAPI/Swagger spec') .option('-o, --output ', 'Output directory', './src/api/generated') .option('--no-mock', 'Skip mock data generation') - .action(async (options: { input: string; output: string; mock: boolean }) => { - const inputPath = resolve(options.input) + .option('--split', 'Split output into per-tag feature folders') + .action(async (options: { input?: string; output: string; mock: boolean; split?: boolean }) => { + const inputValue = options.input ?? (await promptForInput()) + const isUrlInput = inputValue.startsWith('http://') || inputValue.startsWith('https://') + const inputPath = isUrlInput ? inputValue : resolve(inputValue) const outputPath = resolve(options.output) console.log(`Reading spec from ${inputPath}`) @@ -30,7 +75,7 @@ program console.log(`Found ${ir.operations.length} operations, ${ir.schemas.length} schemas`) - writeGeneratedFiles(ir, outputPath, { mock: options.mock }) + writeGeneratedFiles(ir, outputPath, { mock: options.mock, split: options.split }) console.log(`Generated files written to ${outputPath}`) }) From 852ab2517bdb0d62f7c26cfc4627c6d1bcdb2436 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:25:51 +0700 Subject: [PATCH 04/13] feat: support loading specs from URLs in loadSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadSpec now accepts http:// and https:// URLs in addition to local file paths. Includes JSON/YAML parsing and Swagger 2→3 conversion for remote specs. --- src/loader.ts | 49 +++++++++++++++++++++++---- tests/loader.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/loader.ts b/src/loader.ts index 7cf4e87..27ced1e 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -15,13 +15,50 @@ function detectSpecVersion(spec: Record): SpecVersion { return 'unknown' } -async function loadSpec(filePath: string): Promise> { - const raw = readFileSync(filePath, 'utf8') - const parsed = filePath.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) +function isUrl(input: string): boolean { + return input.startsWith('http://') || input.startsWith('https://') +} + +async function loadSpecFromUrl(url: string): Promise> { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch spec from ${url}: ${response.status} ${response.statusText}`) + } + + const text = await response.text() + + let parsed: Record + try { + parsed = JSON.parse(text) + } catch { + parsed = parseYaml(text) as Record + } + + const version = detectSpecVersion(parsed) + + if (version === 'unknown') { + throw new Error(`Unrecognized spec format from ${url}`) + } + + if (version === 'swagger2') { + const result = await converter.convertObj(parsed, { patch: true, warnOnly: true }) + return result.openapi as Record + } + + return parsed +} + +async function loadSpec(input: string): Promise> { + if (isUrl(input)) { + return loadSpecFromUrl(input) + } + + const raw = readFileSync(input, 'utf8') + const parsed = input.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) const version = detectSpecVersion(parsed) if (version === 'unknown') { - throw new Error(`Unrecognized spec format in ${filePath}`) + throw new Error(`Unrecognized spec format in ${input}`) } if (version === 'swagger2') { @@ -30,9 +67,9 @@ async function loadSpec(filePath: string): Promise> { } const config = await createConfig({}) - const result = await bundle({ ref: filePath, config }) + const result = await bundle({ ref: input, config }) return result.bundle.parsed as Record } -export { loadSpec, detectSpecVersion } +export { loadSpec, detectSpecVersion, isUrl } export type { SpecVersion } diff --git a/tests/loader.test.ts b/tests/loader.test.ts index 4c9c6d6..eaeb55a 100644 --- a/tests/loader.test.ts +++ b/tests/loader.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterAll } from 'vitest' import { resolve } from 'path' -import { loadSpec, detectSpecVersion } from '../src/loader' +import { readFileSync } from 'fs' +import { createServer, type Server } from 'http' +import { loadSpec, detectSpecVersion, isUrl } from '../src/loader' describe('detectSpecVersion', () => { it('detects swagger 2.0', () => { @@ -16,6 +18,22 @@ describe('detectSpecVersion', () => { }) }) +describe('isUrl', () => { + it('returns true for http URLs', () => { + expect(isUrl('http://example.com/openapi.json')).toBe(true) + }) + + it('returns true for https URLs', () => { + expect(isUrl('https://api.example.com/v3/openapi.yaml')).toBe(true) + }) + + it('returns false for file paths', () => { + expect(isUrl('./specs/petstore.yaml')).toBe(false) + expect(isUrl('/absolute/path/spec.json')).toBe(false) + expect(isUrl('relative/path.yaml')).toBe(false) + }) +}) + describe('loadSpec', () => { it('loads OpenAPI 3.0 spec', async () => { const spec = await loadSpec(resolve(__dirname, 'fixtures/petstore-oas3.yaml')) @@ -30,3 +48,62 @@ describe('loadSpec', () => { expect(spec.paths['/pets']).toBeDefined() }) }) + +describe('loadSpec from URL', () => { + let server: Server + let baseUrl: string + + const fixturePath = resolve(__dirname, 'fixtures/petstore-oas3.yaml') + const fixtureContent = readFileSync(fixturePath, 'utf8') + + // Start a local HTTP server serving the fixture + const startServer = () => + new Promise((resolve) => { + server = createServer((req, res) => { + if (req.url === '/spec.yaml') { + res.writeHead(200, { 'Content-Type': 'text/yaml' }) + res.end(fixtureContent) + } else if (req.url === '/spec.json') { + const { parse } = require('yaml') + const parsed = parse(fixtureContent) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(parsed)) + } else if (req.url === '/not-found') { + res.writeHead(404) + res.end('Not Found') + } else { + res.writeHead(400) + res.end('Bad Request') + } + }) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr && typeof addr === 'object') { + baseUrl = `http://127.0.0.1:${addr.port}` + } + resolve() + }) + }) + + it('loads OpenAPI spec from a YAML URL', async () => { + await startServer() + const spec = await loadSpec(`${baseUrl}/spec.yaml`) + expect(spec.openapi).toMatch(/^3\./) + expect(spec.paths['/pets']).toBeDefined() + expect(spec.components?.schemas?.Pet).toBeDefined() + }) + + it('loads OpenAPI spec from a JSON URL', async () => { + const spec = await loadSpec(`${baseUrl}/spec.json`) + expect(spec.openapi).toMatch(/^3\./) + expect(spec.paths['/pets']).toBeDefined() + }) + + it('throws on HTTP error response', async () => { + await expect(loadSpec(`${baseUrl}/not-found`)).rejects.toThrow('Failed to fetch spec') + }) + + afterAll(() => { + server?.close() + }) +}) From 36ea5c5463fd7fc969f0a0f1c5fb182db0fcaccc Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:26:31 +0700 Subject: [PATCH 05/13] feat: add --split flag to generate per-tag feature folders When --split is enabled, output is grouped into subdirectories by the first OpenAPI tag on each operation. Each folder gets its own types, hooks, mocks, and index files with a root barrel re-export. --- src/generators/hooks.ts | 5 +- src/generators/index-file.ts | 19 +++++- src/generators/mocks.ts | 69 +++++++++++++++++--- src/writer.ts | 95 +++++++++++++++++++++++++-- tests/e2e.test.ts | 81 +++++++++++++++++++++++ tests/fixtures/tagged-api.yaml | 115 +++++++++++++++++++++++++++++++++ tests/generators/mocks.test.ts | 40 +++++++++++- 7 files changed, 405 insertions(+), 19 deletions(-) create mode 100644 tests/fixtures/tagged-api.yaml diff --git a/src/generators/hooks.ts b/src/generators/hooks.ts index 052b86b..663b59e 100644 --- a/src/generators/hooks.ts +++ b/src/generators/hooks.ts @@ -181,8 +181,9 @@ function collectMockImports(ir: IR): string[] { return [...mocks] } -function generateHooks(ir: IR, options?: { mock?: boolean }): string { +function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: string }): string { const mock = options?.mock ?? true + const providerImportPath = options?.providerImportPath ?? './test-mode-provider' const parts: string[] = [] const queryOps = ir.operations.filter(op => op.method === 'get') const mutationOps = ir.operations.filter(op => op.method !== 'get') @@ -199,7 +200,7 @@ function generateHooks(ir: IR, options?: { mock?: boolean }): string { parts.push(`import { ${tanstackImports.join(', ')} } from '@tanstack/react-query'`) if (mock) { const mockImports = collectMockImports(ir) - parts.push(`import { useApiTestMode } from './test-mode-provider'`) + parts.push(`import { useApiTestMode } from '${providerImportPath}'`) parts.push(`import { ${mockImports.join(', ')} } from './mocks'`) } if (typeImports.length > 0) { diff --git a/src/generators/index-file.ts b/src/generators/index-file.ts index ef4a022..0b81556 100644 --- a/src/generators/index-file.ts +++ b/src/generators/index-file.ts @@ -15,4 +15,21 @@ function generateIndexFile(options?: { mock?: boolean }): string { return lines.join('\n') } -export { generateIndexFile } +function generateRootIndexFile(tagSlugs: string[], options?: { mock?: boolean }): string { + const mock = options?.mock ?? true + const lines = [ + '/* eslint-disable */', + '/* This file is auto-generated by apigen. Do not edit. */', + '', + ] + for (const slug of tagSlugs) { + lines.push(`export * from './${slug}'`) + } + if (mock) { + lines.push("export * from './test-mode-provider'") + } + lines.push('') + return lines.join('\n') +} + +export { generateIndexFile, generateRootIndexFile } diff --git a/src/generators/mocks.ts b/src/generators/mocks.ts index a027039..79c02a8 100644 --- a/src/generators/mocks.ts +++ b/src/generators/mocks.ts @@ -1,15 +1,60 @@ +import { faker } from '@faker-js/faker' import type { IR, IRSchema, IROperation, IRProperty, IRSchemaRef } from '../ir' +// Fixed seed for deterministic output across runs +faker.seed(42) + function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) } -function defaultValueForType(type: string): string { +function fakerValueForField(name: string, type: string): string { + const lower = name.toLowerCase() + + // Name-based heuristics (checked first) + if (lower === 'id' || lower.endsWith('id')) return `'${faker.string.uuid()}'` + if (lower === 'email' || lower.endsWith('email')) return `'${faker.internet.email()}'` + if (lower === 'phone' || lower === 'fax' || lower.endsWith('phone')) return `'${faker.phone.number()}'` + if (lower === 'name' || lower === 'shortname' || lower === 'fullname' || lower === 'displayname') return `'${faker.person.fullName()}'` + if (lower === 'firstname' || lower === 'givenname') return `'${faker.person.firstName()}'` + if (lower === 'lastname' || lower === 'surname' || lower === 'familyname') return `'${faker.person.lastName()}'` + if (lower === 'username') return `'${faker.internet.username()}'` + if (lower === 'street' || lower === 'streetname' || lower === 'address') return `'${faker.location.street()}'` + if (lower === 'postcode' || lower === 'zipcode' || lower === 'postalcode' || lower === 'zip') return `'${faker.location.zipCode()}'` + if (lower === 'city' || lower === 'location') return `'${faker.location.city()}'` + if (lower === 'country') return `'${faker.location.country()}'` + if (lower === 'countrycode') return `'${faker.location.countryCode()}'` + if (lower === 'url' || lower === 'website' || lower === 'homepage' || lower.endsWith('url')) return `'${faker.internet.url()}'` + if (lower === 'description' || lower === 'summary' || lower === 'bio') return `'${faker.lorem.sentence()}'` + if (lower === 'message' || lower === 'comment' || lower === 'note' || lower === 'notes') return `'${faker.lorem.sentence()}'` + if (lower === 'title' || lower === 'subject') return `'${faker.lorem.words(3)}'` + if (lower === 'statuscode') return '200' + if (lower === 'year') return `${faker.date.recent().getFullYear()}` + if (lower === 'quarter') return `${faker.number.int({ min: 1, max: 4 })}` + if (lower === 'month') return `${faker.number.int({ min: 1, max: 12 })}` + if (lower === 'day') return `${faker.number.int({ min: 1, max: 28 })}` + if (lower === 'page' || lower === 'pagenumber') return `${faker.number.int({ min: 1, max: 10 })}` + if (lower === 'limit' || lower === 'pagesize' || lower === 'size') return `${faker.number.int({ min: 10, max: 50 })}` + if (lower === 'total' || lower === 'count' || lower === 'totalcount') return `${faker.number.int({ min: 1, max: 100 })}` + if (lower === 'query' || lower === 'search' || lower === 'keyword') return `'${faker.lorem.word()}'` + if (lower === 'tag' || lower === 'label' || lower === 'category') return `'${faker.lorem.word()}'` + if (lower === 'status' || lower === 'state') return `'active'` + if (lower === 'type' || lower === 'kind') return `'default'` + if (lower === 'code') return `'${faker.string.alphanumeric(6).toUpperCase()}'` + if (lower === 'token') return `'${faker.string.alphanumeric(32)}'` + if (lower === 'password' || lower === 'secret') return `'${faker.internet.password()}'` + if (lower === 'avatar' || lower === 'image' || lower === 'photo' || lower === 'picture') return `'${faker.image.url()}'` + if (lower === 'color' || lower === 'colour') return `'${faker.color.human()}'` + if (lower === 'createdat' || lower === 'updatedat' || lower === 'date' || lower === 'timestamp' || lower.endsWith('date') || lower.endsWith('at')) return `'${faker.date.recent().toISOString()}'` + + // Type-based fallback switch (type) { - case 'number': return '1' - case 'boolean': return 'true' - case 'string': return "'string'" - default: return "'unknown'" + case 'string': return `'${faker.lorem.word()}'` + case 'number': return `${faker.number.int({ min: 1, max: 100 })}` + case 'boolean': return `${faker.datatype.boolean()}` + case 'object': return '{}' + case 'unknown': return 'null as unknown' + default: return 'null as unknown' } } @@ -19,13 +64,13 @@ function mockPropertyValue(prop: IRProperty, schemas: IRSchema[]): string { return `mock${refName}` } if (prop.isArray) { - if (prop.itemType) return `[${defaultValueForType(prop.itemType)}]` + if (prop.itemType) return `[${fakerValueForField(prop.name, prop.itemType)}]` return '[]' } if (prop.enumValues && prop.enumValues.length > 0) { return `'${prop.enumValues[0]}'` } - return defaultValueForType(prop.type) + return fakerValueForField(prop.name, prop.type) } function refToSchemaName(ref: string | null): string | null { @@ -43,11 +88,14 @@ function generateSchemaMock(schema: IRSchema, allSchemas: IRSchema[]): string { return lines.join('\n') } -function generateResponseMock(op: IROperation): string | null { +function generateResponseMock(op: IROperation, emittedNames: Set): string | null { if (!op.responseSchema) return null const name = `mock${capitalize(op.operationId)}Response` + // Skip if already emitted by the schema mock loop + if (emittedNames.has(name)) return null + if (op.responseSchema.isArray) { const itemRef = refToSchemaName(op.responseSchema.itemRef) if (itemRef) { @@ -67,6 +115,7 @@ function generateResponseMock(op: IROperation): string | null { function generateMocks(ir: IR): string { const parts: string[] = [] const usedTypes = new Set() + const emittedNames = new Set() parts.push('/* eslint-disable */') parts.push('/* This file is auto-generated by apigen. Do not edit. */') @@ -83,12 +132,14 @@ function generateMocks(ir: IR): string { } for (const schema of ir.schemas) { + const mockName = `mock${schema.name}` + emittedNames.add(mockName) parts.push(generateSchemaMock(schema, ir.schemas)) parts.push('') } for (const op of ir.operations) { - const responseMock = generateResponseMock(op) + const responseMock = generateResponseMock(op, emittedNames) if (responseMock) { parts.push(responseMock) parts.push('') diff --git a/src/writer.ts b/src/writer.ts index 269a6ed..625fe58 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -1,15 +1,53 @@ import { mkdirSync, writeFileSync } from 'fs' import { join } from 'path' -import type { IR } from './ir' +import type { IR, IROperation, IRSchema } from './ir' import { generateTypes } from './generators/types' import { generateHooks } from './generators/hooks' import { generateMocks } from './generators/mocks' import { generateProvider } from './generators/provider' -import { generateIndexFile } from './generators/index-file' +import { generateIndexFile, generateRootIndexFile } from './generators/index-file' -function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean }): void { - const mock = options?.mock ?? true +function tagSlug(tag: string): string { + return tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') +} + +function collectSchemaNamesForOperations(ops: IROperation[]): Set { + const names = new Set() + for (const op of ops) { + if (op.responseSchema?.ref) { + const name = op.responseSchema.ref.split('/').pop() + if (name) names.add(name) + } + if (op.responseSchema?.isArray && op.responseSchema.itemRef) { + const name = op.responseSchema.itemRef.split('/').pop() + if (name) names.add(name) + } + if (op.requestBody?.ref) { + const name = op.requestBody.ref.split('/').pop() + if (name) names.add(name) + } + } + return names +} + +function groupOperationsByTag(operations: IROperation[]): Map { + const groups = new Map() + for (const op of operations) { + const tag = op.tags.length > 0 ? op.tags[0] : 'common' + const slug = tagSlug(tag) + if (!groups.has(slug)) groups.set(slug, []) + groups.get(slug)!.push(op) + } + return groups +} + +function buildSubsetIR(ops: IROperation[], allSchemas: IRSchema[]): IR { + const neededNames = collectSchemaNamesForOperations(ops) + const schemas = allSchemas.filter(s => neededNames.has(s.name)) + return { operations: ops, schemas } +} +function writeFlat(ir: IR, outputDir: string, mock: boolean): void { mkdirSync(outputDir, { recursive: true }) writeFileSync(join(outputDir, 'types.ts'), generateTypes(ir), 'utf8') @@ -21,4 +59,53 @@ function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boole writeFileSync(join(outputDir, 'index.ts'), generateIndexFile({ mock }), 'utf8') } +function writeSplit(ir: IR, outputDir: string, mock: boolean): void { + mkdirSync(outputDir, { recursive: true }) + + const groups = groupOperationsByTag(ir.operations) + const tagSlugs: string[] = [...groups.keys()].sort() + + // Write shared provider at root + if (mock) { + writeFileSync(join(outputDir, 'test-mode-provider.tsx'), generateProvider(), 'utf8') + } + + // Write per-tag feature folders + for (const slug of tagSlugs) { + const ops = groups.get(slug)! + const subsetIR = buildSubsetIR(ops, ir.schemas) + const featureDir = join(outputDir, slug) + mkdirSync(featureDir, { recursive: true }) + + writeFileSync(join(featureDir, 'types.ts'), generateTypes(subsetIR), 'utf8') + writeFileSync( + join(featureDir, 'hooks.ts'), + generateHooks(subsetIR, { mock, providerImportPath: '../test-mode-provider' }), + 'utf8', + ) + if (mock) { + writeFileSync(join(featureDir, 'mocks.ts'), generateMocks(subsetIR), 'utf8') + } + writeFileSync(join(featureDir, 'index.ts'), generateIndexFile({ mock }), 'utf8') + } + + // Write root index that re-exports all feature folders + writeFileSync( + join(outputDir, 'index.ts'), + generateRootIndexFile(tagSlugs, { mock }), + 'utf8', + ) +} + +function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean }): void { + const mock = options?.mock ?? true + const split = options?.split ?? false + + if (split) { + writeSplit(ir, outputDir, mock) + } else { + writeFlat(ir, outputDir, mock) + } +} + export { writeGeneratedFiles } diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index ed2cf25..74a6042 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -98,6 +98,87 @@ describe('e2e: inline schemas (masterdata-style)', () => { }) }) +describe('e2e: --split flag', () => { + it('generates per-tag feature folders when split is enabled', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/tagged-api.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-e2e-')) + + try { + writeGeneratedFiles(ir, outDir, { split: true }) + + // Root should have test-mode-provider and root index + expect(existsSync(join(outDir, 'test-mode-provider.tsx'))).toBe(true) + expect(existsSync(join(outDir, 'index.ts'))).toBe(true) + + // Should NOT have flat types/hooks/mocks at root + expect(existsSync(join(outDir, 'types.ts'))).toBe(false) + expect(existsSync(join(outDir, 'hooks.ts'))).toBe(false) + expect(existsSync(join(outDir, 'mocks.ts'))).toBe(false) + + // Users tag folder + expect(existsSync(join(outDir, 'users', 'types.ts'))).toBe(true) + expect(existsSync(join(outDir, 'users', 'hooks.ts'))).toBe(true) + expect(existsSync(join(outDir, 'users', 'mocks.ts'))).toBe(true) + expect(existsSync(join(outDir, 'users', 'index.ts'))).toBe(true) + + const usersTypes = readFileSync(join(outDir, 'users', 'types.ts'), 'utf8') + expect(usersTypes).toContain('export interface User') + expect(usersTypes).not.toContain('export interface Post') + + const usersHooks = readFileSync(join(outDir, 'users', 'hooks.ts'), 'utf8') + expect(usersHooks).toContain('useListUsers') + expect(usersHooks).toContain('useCreateUser') + expect(usersHooks).not.toContain('useListPosts') + // Hooks import provider from parent directory + expect(usersHooks).toContain("from '../test-mode-provider'") + + // Posts tag folder + expect(existsSync(join(outDir, 'posts', 'types.ts'))).toBe(true) + const postsHooks = readFileSync(join(outDir, 'posts', 'hooks.ts'), 'utf8') + expect(postsHooks).toContain('useListPosts') + expect(postsHooks).not.toContain('useListUsers') + + // Common folder for untagged operations + expect(existsSync(join(outDir, 'common', 'hooks.ts'))).toBe(true) + const commonHooks = readFileSync(join(outDir, 'common', 'hooks.ts'), 'utf8') + expect(commonHooks).toContain('useHealthCheck') + + // Root index re-exports all feature folders + const rootIndex = readFileSync(join(outDir, 'index.ts'), 'utf8') + expect(rootIndex).toContain("export * from './common'") + expect(rootIndex).toContain("export * from './posts'") + expect(rootIndex).toContain("export * from './users'") + expect(rootIndex).toContain("export * from './test-mode-provider'") + } finally { + rmSync(outDir, { recursive: true }) + } + }) + + it('generates split output without mocks when mock is false', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/tagged-api.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-e2e-')) + + try { + writeGeneratedFiles(ir, outDir, { split: true, mock: false }) + + // Root should not have provider + expect(existsSync(join(outDir, 'test-mode-provider.tsx'))).toBe(false) + + // Feature folders have types and hooks but no mocks + expect(existsSync(join(outDir, 'users', 'types.ts'))).toBe(true) + expect(existsSync(join(outDir, 'users', 'hooks.ts'))).toBe(true) + expect(existsSync(join(outDir, 'users', 'mocks.ts'))).toBe(false) + + const hooks = readFileSync(join(outDir, 'users', 'hooks.ts'), 'utf8') + expect(hooks).not.toContain('useApiTestMode') + } finally { + rmSync(outDir, { recursive: true }) + } + }) +}) + describe('e2e: --no-mock flag', () => { it('generates only types, hooks, and index when mock is false', async () => { const spec = await loadSpec(resolve(__dirname, 'fixtures/petstore-oas3.yaml')) diff --git a/tests/fixtures/tagged-api.yaml b/tests/fixtures/tagged-api.yaml new file mode 100644 index 0000000..5430acc --- /dev/null +++ b/tests/fixtures/tagged-api.yaml @@ -0,0 +1,115 @@ +openapi: "3.0.3" +info: + title: Tagged API + version: "1.0" +paths: + /users: + get: + operationId: listUsers + tags: + - Users + responses: + "200": + description: A list of users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + post: + operationId: createUser + tags: + - Users + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserBody" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /posts: + get: + operationId: listPosts + tags: + - Posts + responses: + "200": + description: A list of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + post: + operationId: createPost + tags: + - Posts + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePostBody" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + /health: + get: + operationId: healthCheck + responses: + "200": + description: OK +components: + schemas: + User: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + email: + type: string + CreateUserBody: + type: object + required: + - name + properties: + name: + type: string + email: + type: string + Post: + type: object + required: + - id + - title + properties: + id: + type: integer + title: + type: string + body: + type: string + CreatePostBody: + type: object + required: + - title + properties: + title: + type: string + body: + type: string diff --git a/tests/generators/mocks.test.ts b/tests/generators/mocks.test.ts index 1b68583..a772dee 100644 --- a/tests/generators/mocks.test.ts +++ b/tests/generators/mocks.test.ts @@ -5,14 +5,17 @@ import { extractIR } from '../../src/ir' import { generateMocks } from '../../src/generators/mocks' describe('generateMocks', () => { - it('generates mock constants for schemas', async () => { + it('generates mock constants for schemas with realistic values', async () => { const spec = await loadSpec(resolve(__dirname, '../fixtures/petstore-oas3.yaml')) const ir = extractIR(spec) const output = generateMocks(ir) expect(output).toContain('export const mockPet: Pet') - expect(output).toContain('id: 1') - expect(output).toContain("name: 'string'") + // id field should get a UUID (faker heuristic for "id") + expect(output).toMatch(/id: '[0-9a-f-]+'/) + // name field should get a realistic name (not 'string') + expect(output).not.toContain("name: 'string'") + expect(output).toContain('name:') }) it('generates mock response data for operations', async () => { @@ -40,4 +43,35 @@ describe('generateMocks', () => { expect(output).not.toContain('import type') expect(output).not.toContain('import type { }') }) + + it('does not emit duplicate response mocks for inline schemas', async () => { + const spec = await loadSpec(resolve(__dirname, '../fixtures/inline-schemas.yaml')) + const ir = extractIR(spec) + const output = generateMocks(ir) + + // Should have exactly one declaration of each mock + const searchResponseMatches = output.match(/export const mockSearchBgInsuranceResponse/g) + expect(searchResponseMatches).toHaveLength(1) + + const getByIdResponseMatches = output.match(/export const mockGetByIdBgInsuranceResponse/g) + expect(getByIdResponseMatches).toHaveLength(1) + }) + + it('generates {} for object type and null as unknown for unknown type', () => { + const ir = { + operations: [], + schemas: [{ + name: 'TestSchema', + properties: [ + { name: 'data', type: 'object', required: true, isArray: false, itemType: null, ref: null, enumValues: null }, + { name: 'meta', type: 'unknown', required: false, isArray: false, itemType: null, ref: null, enumValues: null }, + ], + required: ['data'], + }], + } + const output = generateMocks(ir) + + expect(output).toContain('data: {},') + expect(output).toContain('meta: null as unknown,') + }) }) From cd2e8c50035dab968ee7ce03c37f05d67954215c Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:26:47 +0700 Subject: [PATCH 06/13] test: add YAML response and invalid content tests for discover Adds 2 edge-case tests from code review suggestions: discovering specs served as YAML and skipping 200 responses with non-spec content. Also tracks the interactive spec source design and plan. --- ...26-02-26-interactive-spec-source-design.md | 65 +++ ...2026-02-26-interactive-spec-source-plan.md | 380 ++++++++++++++++++ tests/discover.test.ts | 61 +++ 3 files changed, 506 insertions(+) create mode 100644 docs/plans/2026-02-26-interactive-spec-source-design.md create mode 100644 docs/plans/2026-02-26-interactive-spec-source-plan.md diff --git a/docs/plans/2026-02-26-interactive-spec-source-design.md b/docs/plans/2026-02-26-interactive-spec-source-design.md new file mode 100644 index 0000000..ad106aa --- /dev/null +++ b/docs/plans/2026-02-26-interactive-spec-source-design.md @@ -0,0 +1,65 @@ +# Interactive Spec Source Selection + +**Date**: 2026-02-26 +**Status**: Approved + +## Problem + +When a user runs `apigen generate` without the `-i` flag, the CLI errors out. Users should instead be guided through choosing how to provide their API spec. + +## Design + +### CLI Flow + +Change `-i` from `requiredOption` to `option`. When omitted, show an interactive prompt: + +``` +? How would you like to provide your API spec? +❯ Local file path + Direct URL to spec + Auto-discover from base URL +``` + +Each choice leads to a follow-up `input()` prompt for the path or URL. When `-i` is provided, the prompt is skipped entirely — no breaking change to existing usage. + +### Source Options + +1. **Local file path** — user enters a path to a `.yaml` or `.json` file +2. **Direct URL** — user enters a full URL to the spec endpoint +3. **Auto-discover** — user enters a base URL (e.g. `http://localhost:8080`), the tool tries well-known paths + +### Auto-Discovery + +New `src/discover.ts` with: + +```ts +discoverSpec(baseUrl: string): Promise<{ url: string; version: SpecVersion }> +``` + +Well-known paths tried in order: +1. `/v3/api-docs` — Spring Boot (SpringDoc) +2. `/swagger.json` — Swagger UI / Express swagger-jsdoc +3. `/openapi.json` — Common convention +4. `/api-docs` — Older Spring Boot (Springfox) +5. `/docs/openapi.json` — FastAPI + +Each path is fetched with a 3-second timeout. The response is parsed as JSON/YAML and validated with `detectSpecVersion()` (reused from `loader.ts`). First valid response wins. If all fail, an error lists every path tried. + +### Dependencies + +- Add `@inquirer/prompts` as a `dependency` (needed at runtime since CLI is the shipped artifact) + +### Files Changed + +| File | Change | +|------|--------| +| `src/cli.ts` | Make `-i` optional, add `promptForInput()` using `@inquirer/prompts` | +| `src/discover.ts` | New — `discoverSpec()` function | +| `package.json` | Add `@inquirer/prompts` | +| `tests/discover.test.ts` | New — unit tests for discovery logic | + +### Testing + +- **discover.ts**: Mock `fetch` to test success path, fallback order, timeout, and all-fail case +- **Existing tests**: Unchanged — all use `-i` directly +- **Interactive prompt**: Not auto-tested (thin wrapper over `@inquirer/prompts`) diff --git a/docs/plans/2026-02-26-interactive-spec-source-plan.md b/docs/plans/2026-02-26-interactive-spec-source-plan.md new file mode 100644 index 0000000..b35c746 --- /dev/null +++ b/docs/plans/2026-02-26-interactive-spec-source-plan.md @@ -0,0 +1,380 @@ +# Interactive Spec Source Selection — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** When `-i` is omitted from `apigen generate`, show an interactive prompt letting the user choose between local file, direct URL, or auto-discover from a base URL. + +**Architecture:** Add `@inquirer/prompts` for the interactive menu. Create a `src/discover.ts` module for auto-discovery logic (try well-known API doc paths against a base URL). Modify `cli.ts` to fall back to the interactive prompt when `-i` is missing. + +**Tech Stack:** TypeScript, @inquirer/prompts (select + input), Node fetch API + +**Design doc:** `docs/plans/2026-02-26-interactive-spec-source-design.md` + +--- + +### Task 1: Install @inquirer/prompts + +**Files:** +- Modify: `package.json` + +**Step 1: Install the dependency** + +Run: `bun add @inquirer/prompts` + +**Step 2: Verify installation** + +Run: `bun install && bun run typecheck` +Expected: No errors + +**Step 3: Commit** + +```bash +git add package.json bun.lock +git commit -m "chore: add @inquirer/prompts dependency" +``` + +--- + +### Task 2: Create discover.ts with tests (TDD) + +**Files:** +- Create: `src/discover.ts` +- Create: `tests/discover.test.ts` + +**Step 1: Write the failing tests** + +Create `tests/discover.test.ts`: + +```ts +import { describe, it, expect, afterAll, beforeAll } from 'vitest' +import { createServer, type Server } from 'http' +import { discoverSpec, WELL_KNOWN_PATHS } from '../src/discover' + +describe('discoverSpec', () => { + let server: Server + let baseUrl: string + + beforeAll(async () => { + server = createServer((req, res) => { + if (req.url === '/v3/api-docs') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ openapi: '3.0.3', info: { title: 'Test', version: '1.0' }, paths: {} })) + } else if (req.url === '/swagger.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ swagger: '2.0', info: { title: 'Test', version: '1.0' }, paths: {} })) + } else { + res.writeHead(404) + res.end('Not Found') + } + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr && typeof addr === 'object') { + baseUrl = `http://127.0.0.1:${addr.port}` + } + resolve() + }) + }) + }) + + afterAll(() => { + server?.close() + }) + + it('discovers OpenAPI 3.x spec at first matching path', async () => { + const result = await discoverSpec(baseUrl) + expect(result.url).toBe(`${baseUrl}/v3/api-docs`) + expect(result.version).toBe('openapi3') + }) + + it('discovers Swagger 2.0 spec when only swagger.json is available', async () => { + // Create a server that only serves /swagger.json + const swaggerServer = createServer((req, res) => { + if (req.url === '/swagger.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ swagger: '2.0', info: { title: 'Test', version: '1.0' }, paths: {} })) + } else { + res.writeHead(404) + res.end('Not Found') + } + }) + + const swaggerBaseUrl = await new Promise((resolve) => { + swaggerServer.listen(0, '127.0.0.1', () => { + const addr = swaggerServer.address() + if (addr && typeof addr === 'object') { + resolve(`http://127.0.0.1:${addr.port}`) + } + }) + }) + + try { + const result = await discoverSpec(swaggerBaseUrl) + expect(result.url).toBe(`${swaggerBaseUrl}/swagger.json`) + expect(result.version).toBe('swagger2') + } finally { + swaggerServer.close() + } + }) + + it('throws when no spec is found at any well-known path', async () => { + // Create a server that returns 404 for everything + const emptyServer = createServer((_req, res) => { + res.writeHead(404) + res.end('Not Found') + }) + + const emptyBaseUrl = await new Promise((resolve) => { + emptyServer.listen(0, '127.0.0.1', () => { + const addr = emptyServer.address() + if (addr && typeof addr === 'object') { + resolve(`http://127.0.0.1:${addr.port}`) + } + }) + }) + + try { + await expect(discoverSpec(emptyBaseUrl)).rejects.toThrow('Could not find an API spec') + } finally { + emptyServer.close() + } + }) + + it('strips trailing slash from base URL', async () => { + const result = await discoverSpec(`${baseUrl}/`) + expect(result.url).toBe(`${baseUrl}/v3/api-docs`) + }) + + it('exports the well-known paths list', () => { + expect(WELL_KNOWN_PATHS).toEqual([ + '/v3/api-docs', + '/swagger.json', + '/openapi.json', + '/api-docs', + '/docs/openapi.json', + ]) + }) +}) +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test tests/discover.test.ts` +Expected: FAIL — module `../src/discover` does not exist + +**Step 3: Implement discover.ts** + +Create `src/discover.ts`: + +```ts +import { parse as parseYaml } from 'yaml' +import { detectSpecVersion } from './loader' +import type { SpecVersion } from './loader' + +const WELL_KNOWN_PATHS = [ + '/v3/api-docs', + '/swagger.json', + '/openapi.json', + '/api-docs', + '/docs/openapi.json', +] as const + +interface DiscoverResult { + url: string + version: SpecVersion +} + +async function discoverSpec(baseUrl: string): Promise { + const normalizedBase = baseUrl.replace(/\/+$/, '') + const tried: string[] = [] + + for (const path of WELL_KNOWN_PATHS) { + const url = `${normalizedBase}${path}` + tried.push(url) + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(3000) }) + if (!response.ok) continue + + const text = await response.text() + let parsed: Record + try { + parsed = JSON.parse(text) + } catch { + parsed = parseYaml(text) as Record + } + + const version = detectSpecVersion(parsed) + if (version !== 'unknown') { + return { url, version } + } + } catch { + continue + } + } + + throw new Error( + `Could not find an API spec at ${normalizedBase}. Tried:\n${tried.map((u) => ` - ${u}`).join('\n')}` + ) +} + +export { discoverSpec, WELL_KNOWN_PATHS } +export type { DiscoverResult } +``` + +**Step 4: Run tests to verify they pass** + +Run: `bun test tests/discover.test.ts` +Expected: All 5 tests PASS + +**Step 5: Run full test suite to check for regressions** + +Run: `bun test` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add src/discover.ts tests/discover.test.ts +git commit -m "feat: add auto-discovery of API specs at well-known paths" +``` + +--- + +### Task 3: Update cli.ts with interactive prompt + +**Files:** +- Modify: `src/cli.ts:19` — change `requiredOption` to `option` +- Modify: `src/cli.ts:23-37` — add `promptForInput()` call when `options.input` is undefined + +**Step 1: Modify cli.ts** + +Replace the full content of `src/cli.ts` with: + +```ts +#!/usr/bin/env node + +import { Command } from 'commander' +import { resolve } from 'path' +import { select, input } from '@inquirer/prompts' +import { loadSpec } from './loader' +import { extractIR } from './ir' +import { writeGeneratedFiles } from './writer' +import { discoverSpec } from './discover' + +async function promptForInput(): Promise { + const source = await select({ + message: 'How would you like to provide your API spec?', + choices: [ + { name: 'Local file path', value: 'file' }, + { name: 'Direct URL to spec', value: 'url' }, + { name: 'Auto-discover from base URL', value: 'discover' }, + ], + }) + + if (source === 'file') { + const filePath = await input({ + message: 'Enter the file path:', + validate: (v) => (v.trim().length > 0 ? true : 'File path is required'), + }) + return resolve(filePath.trim()) + } + + if (source === 'url') { + const url = await input({ + message: 'Enter the spec URL:', + validate: (v) => + v.startsWith('http://') || v.startsWith('https://') ? true : 'Must be an http:// or https:// URL', + }) + return url.trim() + } + + // source === 'discover' + const baseUrl = await input({ + message: 'Enter your API base URL (e.g. http://localhost:8080):', + validate: (v) => + v.startsWith('http://') || v.startsWith('https://') ? true : 'Must be an http:// or https:// URL', + }) + + console.log('Searching for API spec...') + const result = await discoverSpec(baseUrl.trim()) + console.log(`Found ${result.version === 'swagger2' ? 'Swagger 2.0' : 'OpenAPI 3.x'} spec at ${result.url}`) + return result.url +} + +const program = new Command() + +program + .name('apigen-tanstack') + .description('Generate TanStack Query hooks from OpenAPI/Swagger specs') + .version('0.1.0') + +program + .command('generate') + .description('Generate hooks, types, and mocks from an OpenAPI spec') + .option('-i, --input ', 'Path or URL to OpenAPI/Swagger spec') + .option('-o, --output ', 'Output directory', './src/api/generated') + .option('--no-mock', 'Skip mock data generation') + .option('--split', 'Split output into per-tag feature folders') + .action(async (options: { input?: string; output: string; mock: boolean; split?: boolean }) => { + const inputValue = options.input ?? (await promptForInput()) + const isUrlInput = inputValue.startsWith('http://') || inputValue.startsWith('https://') + const inputPath = isUrlInput ? inputValue : resolve(inputValue) + const outputPath = resolve(options.output) + + console.log(`Reading spec from ${inputPath}`) + + const spec = await loadSpec(inputPath) + const ir = extractIR(spec) + + console.log(`Found ${ir.operations.length} operations, ${ir.schemas.length} schemas`) + + writeGeneratedFiles(ir, outputPath, { mock: options.mock, split: options.split }) + + console.log(`Generated files written to ${outputPath}`) + }) + +await program.parseAsync(process.argv) +``` + +**Step 2: Verify typecheck passes** + +Run: `bun run typecheck` +Expected: No errors + +**Step 3: Run full test suite** + +Run: `bun test` +Expected: All tests pass (existing tests all provide `-i` so they never hit the prompt) + +**Step 4: Commit** + +```bash +git add src/cli.ts +git commit -m "feat: add interactive spec source prompt when -i is omitted" +``` + +--- + +### Task 4: Manual smoke test + +**Step 1: Build the CLI** + +Run: `bun run build` +Expected: Build succeeds + +**Step 2: Test with -i flag (should work as before)** + +Run: `node dist/cli.js generate -i tests/fixtures/petstore-oas3.yaml -o /tmp/apigen-smoke` +Expected: Generates files to `/tmp/apigen-smoke/` without any prompt + +**Step 3: Test without -i flag (should show prompt)** + +Run: `node dist/cli.js generate -o /tmp/apigen-smoke2` +Expected: Shows interactive menu with 3 options. Pick "Local file path", enter `tests/fixtures/petstore-oas3.yaml`, generates files. + +**Step 4: Clean up** + +Run: `rm -rf /tmp/apigen-smoke /tmp/apigen-smoke2` diff --git a/tests/discover.test.ts b/tests/discover.test.ts index 308cde6..3863f2a 100644 --- a/tests/discover.test.ts +++ b/tests/discover.test.ts @@ -99,6 +99,67 @@ describe('discoverSpec', () => { expect(result.url).toBe(`${baseUrl}/v3/api-docs`) }) + it('discovers spec from YAML response', async () => { + const yamlServer = createServer((req, res) => { + if (req.url === '/openapi.json') { + res.writeHead(200, { 'Content-Type': 'text/yaml' }) + res.end('openapi: "3.0.3"\ninfo:\n title: Test\n version: "1.0"\npaths: {}') + } else { + res.writeHead(404) + res.end('Not Found') + } + }) + + const yamlBaseUrl = await new Promise((resolve) => { + yamlServer.listen(0, '127.0.0.1', () => { + const addr = yamlServer.address() + if (addr && typeof addr === 'object') { + resolve(`http://127.0.0.1:${addr.port}`) + } + }) + }) + + try { + const result = await discoverSpec(yamlBaseUrl) + expect(result.url).toBe(`${yamlBaseUrl}/openapi.json`) + expect(result.version).toBe('openapi3') + } finally { + yamlServer.close() + } + }) + + it('skips 200 responses with invalid content', async () => { + const invalidServer = createServer((req, res) => { + if (req.url === '/v3/api-docs') { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end('Not a spec') + } else if (req.url === '/swagger.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ swagger: '2.0', info: { title: 'Test', version: '1.0' }, paths: {} })) + } else { + res.writeHead(404) + res.end('Not Found') + } + }) + + const invalidBaseUrl = await new Promise((resolve) => { + invalidServer.listen(0, '127.0.0.1', () => { + const addr = invalidServer.address() + if (addr && typeof addr === 'object') { + resolve(`http://127.0.0.1:${addr.port}`) + } + }) + }) + + try { + const result = await discoverSpec(invalidBaseUrl) + expect(result.url).toBe(`${invalidBaseUrl}/swagger.json`) + expect(result.version).toBe('swagger2') + } finally { + invalidServer.close() + } + }) + it('exports the well-known paths list', () => { expect(WELL_KNOWN_PATHS).toEqual([ '/v3/api-docs', From fe2e3c8570c82f63cd07fc25aed0d827a9414335 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:43:40 +0700 Subject: [PATCH 07/13] docs: update getting-started with interactive prompt, URL support, and --split --- docs/getting-started.md | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 92c9a36..fa22d76 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,7 +7,7 @@ Generate type-safe TanStack Query hooks from your OpenAPI/Swagger specs in under - **Node.js** >= 18 - **bun** (used for building the package) - A consuming project with: - - `react` ^18 + - `react` ^18 or ^19 - `@tanstack/react-query` ^5 ## Install @@ -21,21 +21,49 @@ npm install --save-dev apigen ### CLI usage ```bash +# From a local file npx apigen generate --input ./openapi.yaml --output ./src/api/generated + +# From a URL +npx apigen generate -i https://api.example.com/openapi.json + +# Interactive mode (omit -i to be prompted) +npx apigen generate ``` -That reads your OpenAPI 3.x or Swagger 2.0 spec (YAML or JSON), and writes generated files to `./src/api/generated`. +That reads your OpenAPI 3.x or Swagger 2.0 spec (YAML or JSON, local file or URL), and writes generated files to `./src/api/generated`. + +When `-i` is omitted, an interactive prompt guides you through three options: local file path, direct URL, or auto-discover from a base URL. ## Generated Output Structure +### Flat output (default) + ``` src/api/generated/ index.ts # Re-exports everything types.ts # TypeScript interfaces from schema definitions + param types hooks.ts # useQuery / useMutation hooks per operation - mocks.ts # Mock data for every schema and response - test-mode-provider.tsx # React context to toggle mock mode + mocks.ts # Mock data for every schema and response (when mock enabled) + test-mode-provider.tsx # React context to toggle mock mode (when mock enabled) +``` + +### Split output (`--split`) + ``` +src/api/generated/ + index.ts # Re-exports all feature folders + test-mode-provider.tsx # Shared provider (when mock enabled) + pets/ + types.ts # Types for pet operations only + hooks.ts # Hooks for pet operations only + mocks.ts # Mocks for pet operations only (when mock enabled) + index.ts # Barrel for this feature + users/ + ... +``` + +Use `--split` to organize output by API tag into per-feature folders. ### What each file does From 842983a015e68dec7fc1118f2ab2e42c0d5aad05 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:44:49 +0700 Subject: [PATCH 08/13] docs: update configuration with optional -i, --split flag, URL support --- docs/configuration.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a5f5f34..ebf8421 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,9 +39,9 @@ function defineConfig(config: ConfigInput): Config ## Config Options -### `input` (required) +### `input` (required for config, optional for CLI) -Path to your OpenAPI 3.x or Swagger 2.0 spec file. Accepts YAML (`.yaml`, `.yml`) or JSON (`.json`). +Path or URL to your OpenAPI 3.x or Swagger 2.0 spec. Accepts local YAML/JSON files or `http://`/`https://` URLs. ```ts defineConfig({ @@ -95,9 +95,9 @@ defineConfig({ npx apigen generate [flags] ``` -### `--input` / `-i` (required) +### `--input` / `-i` -Path to the OpenAPI/Swagger spec file. +Path or URL to the OpenAPI/Swagger spec file. When omitted, an interactive prompt guides you through providing the spec via local file path, direct URL, or auto-discovery from a base URL. ```bash npx apigen generate --input ./openapi.yaml @@ -120,13 +120,22 @@ Skip mock data generation. This is the CLI equivalent of `mock: false` in the co npx apigen generate -i ./openapi.yaml --no-mock ``` +### `--split` + +Split generated output into per-tag feature folders. Each tag gets its own directory with `types.ts`, `hooks.ts`, `mocks.ts`, and `index.ts`. A shared `test-mode-provider.tsx` is placed at the output root. + +```bash +npx apigen generate -i ./openapi.yaml --split +``` + ### Full example ```bash npx apigen generate \ --input ./specs/petstore.yaml \ --output ./src/api \ - --no-mock + --no-mock \ + --split ``` ## Peer Dependencies @@ -135,7 +144,7 @@ Your consuming project must install: | Package | Version | |---------|---------| -| `react` | `^18` | +| `react` | `^18 \|\| ^19` | | `@tanstack/react-query` | `^5` | The generated hooks import from `@tanstack/react-query` directly. The generated test-mode provider imports from `react`. @@ -146,7 +155,7 @@ npm install react @tanstack/react-query ## Generated Files Reference -The output directory always contains five files: +The output directory contains these files (mocks and provider are omitted when `--no-mock` is used): | File | Description | |------|-------------| @@ -160,6 +169,7 @@ The output directory always contains five files: | Option | CLI Flag | Default | |--------|----------|---------| -| `input` | `--input` / `-i` | *(required)* | +| `input` | `--input` / `-i` | *(interactive prompt)* | | `output` | `--output` / `-o` | `./src/api/generated` | | `mock` | `--no-mock` to disable | `true` | +| `split` | `--split` | `false` | From 730965f25b9f3bb3e5f792539fb65a9d8a6d2ea2 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:45:29 +0700 Subject: [PATCH 09/13] docs: update api-reference with optional -i, --split, URL support, discover module --- docs/api-reference.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e63584b..27bccdf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -92,9 +92,12 @@ apigen generate -i ./openapi.yaml | Flag | Required | Default | Description | |--------------------------|----------|-------------------------|------------------------------------------------------| -| `-i, --input ` | Yes | -- | Path to the OpenAPI or Swagger spec file. | +| `-i, --input ` | No | *(interactive prompt)* | Path or URL to the OpenAPI or Swagger spec file. When omitted, shows an interactive prompt. | | `-o, --output ` | No | `./src/api/generated` | Output directory for generated files. | | `--no-mock` | No | (mock enabled) | Skip mock data generation. | +| `--split` | No | (disabled) | Split output into per-tag feature folders. | + +> **Interactive mode:** When `-i` is omitted, apigen prompts you to choose between providing a local file path, a direct URL, or auto-discovering a spec from a base URL (tries well-known paths like `/v3/api-docs`, `/swagger.json`, `/openapi.json`). **Examples** @@ -116,6 +119,8 @@ apigen generate -i ./spec.json -o ./src/generated --no-mock | `test-mode-provider.tsx` | React context provider to switch hooks to mock mode. | | `index.ts` | Barrel file re-exporting all generated modules. | +> When `--no-mock` is used, `mocks.ts` and `test-mode-provider.tsx` are not generated. When `--split` is used, output is organized into per-tag subdirectories. + --- ## Internal modules @@ -125,6 +130,7 @@ The following modules are implementation details and are **not** part of the pub - **loader** (`src/loader.ts`) -- reads and normalizes OpenAPI/Swagger specs. - **ir** (`src/ir.ts`) -- extracts an intermediate representation from a parsed spec. - **generators** (`src/generators/`) -- emit TypeScript source strings from the IR. +- **discover** (`src/discover.ts`) -- auto-discovers API specs at well-known paths. - **writer** (`src/writer.ts`) -- writes generator output to disk. These may change without notice between versions. Import only from the package entrypoint (`apigen`). From 2015d815891fc6c579f9e940555aac85621cb013 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:47:38 +0700 Subject: [PATCH 10/13] docs: update architecture with discover, --split, optional -i, fixed operationId fallback --- docs/architecture.md | 60 +++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0c460cf..901249b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,7 +118,7 @@ interface IROperation { } ``` -**operationId fallback:** If the spec does not define an `operationId`, one is synthesized as `${method}${path}` with non-alphanumeric characters replaced by underscores. Example: `GET /users/{id}` becomes `get_users__id_`. +**operationId fallback:** If the spec does not define an `operationId`, one is synthesized by `generateOperationId(method, path)`. It uses smart heuristics: known action suffixes (`search`, `get-by-id`, `create`, `update`, `delete`, etc.) are recognized, and path segments are converted to PascalCase. Examples: `GET /users` becomes `listUsers`, `POST /users/{id}/search` becomes `searchUsers`, `DELETE /users/{id}` becomes `deleteUsers`. **Response resolution order:** The extractor looks for a success response in this order: `200`, then `201`, then `default`. It only extracts the `application/json` content type. @@ -218,10 +218,11 @@ function generateSomething(ir: IR): string { | Generator | File | Signature | Description | |---|---|---|---| | `generateTypes` | `generators/types.ts` | `(ir: IR) => string` | Schema interfaces + param interfaces | -| `generateHooks` | `generators/hooks.ts` | `(ir: IR) => string` | useQuery/useMutation hooks, apiFetch helper, imports | +| `generateHooks` | `generators/hooks.ts` | `(ir: IR, options?) => string` | useQuery/useMutation hooks, apiFetch helper, imports | | `generateMocks` | `generators/mocks.ts` | `(ir: IR) => string` | Schema mocks + response mocks | | `generateProvider` | `generators/provider.ts` | `() => string` | Static ApiTestModeProvider context (no IR needed) | -| `generateIndexFile` | `generators/index-file.ts` | `() => string` | Static barrel re-exports (no IR needed) | +| `generateIndexFile` | `generators/index-file.ts` | `(options?) => string` | Barrel re-exports (conditional on mock) | +| `generateRootIndexFile` | `generators/index-file.ts` | `(tagSlugs, options?) => string` | Root index re-exporting per-tag feature folders | ### types generator (`generators/types.ts`) @@ -262,7 +263,9 @@ Returns a static string literal with `export * from` statements for each of the ## Stage 4: File Writer (`src/writer.ts`) -**Entry point:** `writeGeneratedFiles(ir: IR, outputDir: string): void` +**Entry point:** `writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean }): void` + +When `mock` is `false`, mocks and provider files are skipped. When `split` is `true`, output is organized into per-tag feature folders. The writer is the orchestrator. It: @@ -271,14 +274,15 @@ The writer is the orchestrator. It: 3. Writes each result to the corresponding file using `writeFileSync`. ```ts -function writeGeneratedFiles(ir: IR, outputDir: string): void { - mkdirSync(outputDir, { recursive: true }) - - writeFileSync(join(outputDir, 'types.ts'), generateTypes(ir), 'utf8') - writeFileSync(join(outputDir, 'hooks.ts'), generateHooks(ir), 'utf8') - writeFileSync(join(outputDir, 'mocks.ts'), generateMocks(ir), 'utf8') - writeFileSync(join(outputDir, 'test-mode-provider.tsx'), generateProvider(), 'utf8') - writeFileSync(join(outputDir, 'index.ts'), generateIndexFile(), 'utf8') +function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean }): void { + const mock = options?.mock ?? true + const split = options?.split ?? false + + if (split) { + writeSplit(ir, outputDir, mock) // per-tag feature folders + } else { + writeFlat(ir, outputDir, mock) // single directory + } } ``` @@ -342,29 +346,51 @@ apigen generate -i [-o ] | Flag | Default | Description | |---|---|---| -| `-i, --input ` | (required) | Path to OpenAPI or Swagger spec file | +| `-i, --input ` | *(interactive prompt)* | Path or URL to OpenAPI or Swagger spec file | | `-o, --output ` | `./src/api/generated` | Output directory for generated files | | `--no-mock` | mocks enabled | Skip mock data generation | +| `--split` | disabled | Split output into per-tag feature folders | Internally, it runs the pipeline in sequence: ```ts -const spec = await loadSpec(inputPath) // Stage 1: load + normalize -const ir = extractIR(spec) // Stage 2: extract IR -writeGeneratedFiles(ir, outputPath) // Stage 3+4: generate + write +const inputValue = options.input ?? (await promptForInput()) // Interactive if -i omitted +const spec = await loadSpec(inputPath) // Stage 1: load + normalize +const ir = extractIR(spec) // Stage 2: extract IR +writeGeneratedFiles(ir, outputPath, { mock, split }) // Stage 3+4: generate + write ``` +When `-i` is omitted, `promptForInput()` (from `@inquirer/prompts`) offers three choices: local file path, direct URL, or auto-discover from a base URL using `discoverSpec()` from `src/discover.ts`. + The CLI logs the number of operations and schemas found, and the output directory path. --- +### Auto-Discovery (`src/discover.ts`) + +**Entry point:** `discoverSpec(baseUrl: string): Promise<{ url: string; version: SpecVersion }>` + +When the user selects "Auto-discover from base URL" in the interactive prompt, this module tries well-known API documentation paths in order: + +| Path | Framework | +|---|---| +| `/v3/api-docs` | Spring Boot (SpringDoc) | +| `/swagger.json` | Swagger UI / Express swagger-jsdoc | +| `/openapi.json` | Common convention | +| `/api-docs` | Older Spring Boot (Springfox) | +| `/docs/openapi.json` | FastAPI | + +Each path is fetched with a 3-second timeout. The response is parsed as JSON (with YAML fallback) and validated with `detectSpecVersion()`. The first valid response wins. If all fail, an error lists every path tried. + +--- + ## Configuration (`src/config.ts`) For programmatic use, apigen exports `defineConfig` and `resolveConfig`: ```ts interface ConfigInput { - input: string // required: path to spec file + input: string // required: path or URL to spec output?: string // default: './src/api/generated' mock?: boolean // default: true } From 79dbac09133bbe46b1320e04a13704360d5ab5ed Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:48:31 +0700 Subject: [PATCH 11/13] docs: add split output structure and --no-mock conditional note --- docs/generated-output.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/generated-output.md b/docs/generated-output.md index a325c87..89979f3 100644 --- a/docs/generated-output.md +++ b/docs/generated-output.md @@ -13,6 +13,28 @@ This document is a file-by-file walkthrough of everything apigen produces. After index.ts # Barrel re-exports ``` +### Split output (`--split`) + +When `--split` is enabled, output is organized by API tag: + +``` +/ + index.ts # Re-exports all feature folders + provider + test-mode-provider.tsx # Shared provider (when mock enabled) + pets/ + types.ts # Types for pet-tagged operations + hooks.ts # Hooks for pet-tagged operations + mocks.ts # Mocks (when mock enabled) + index.ts # Barrel for this feature + users/ + types.ts + hooks.ts + mocks.ts + index.ts +``` + +Operations are grouped by their first tag. Operations with no tag go into a `common/` folder. The tag name is slugified (lowercased, non-alphanumeric replaced with hyphens). + Every file begins with these two lines: ```ts @@ -20,6 +42,8 @@ Every file begins with these two lines: /* This file is auto-generated by apigen. Do not edit. */ ``` +> **Note:** When `--no-mock` is used, `mocks.ts` and `test-mode-provider.tsx` are not generated, and `hooks.ts` omits test mode logic and mock imports. + --- ## types.ts From 186d4a254043e4e46ef155d37969e2381f0872d4 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:49:05 +0700 Subject: [PATCH 12/13] docs: add discover.ts to contributing project structure --- docs/contributing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contributing.md b/docs/contributing.md index da058de..0268f1d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -23,6 +23,7 @@ src/ cli.ts Commander-based CLI (apigen generate) loader.ts Reads YAML/JSON specs, converts Swagger 2 to OpenAPI 3 ir.ts Extracts an intermediate representation from a parsed spec + discover.ts Auto-discovers API specs at well-known paths writer.ts Orchestrates generators and writes files to disk generators/ types.ts Emits TypeScript interfaces for schemas and params @@ -39,6 +40,7 @@ tests/ ir.test.ts writer.test.ts cli.test.ts + discover.test.ts e2e.test.ts generators/ types.test.ts From cb5307a4a3173506944f44929030f1bf81ca0ed9 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 10:51:00 +0700 Subject: [PATCH 13/13] docs: update CLAUDE.md test count and add discover.ts --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4695629..2515758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ apigen is a standalone npm CLI that reads OpenAPI 3.0+ and Swagger 2.0 specs and ## Commands ```bash -bun test # run tests (31 tests across 10 files) +bun test # run tests (56 tests across 11 files) bun run typecheck # tsc --noEmit bun run build # compile to dist/ ``` @@ -32,6 +32,7 @@ src/ ├── index.ts # Public exports (config only) ├── loader.ts # Reads YAML/JSON, converts Swagger 2→3, bundles refs ├── ir.ts # Extracts IR (operations + schemas) from OpenAPI spec +├── discover.ts # Auto-discovers API specs at well-known paths ├── writer.ts # Orchestrates all generators, writes to disk ├── generators/ │ ├── types.ts # IR → TypeScript interfaces