diff --git a/CLAUDE.md b/CLAUDE.md index 0f9a9aa..737be54 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 (56 tests across 11 files) +bun test # run tests (88 tests across 13 files) bun run typecheck # tsc --noEmit bun run build # compile to dist/ ``` diff --git a/README.md b/README.md index b2a66e7..aef3cc4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ function PetList() { - **Mock data + test mode** — static mocks and a React context provider to toggle them on - **Swagger 2.0 support** — auto-converted to OpenAPI 3.x - **Flat or split output** — single directory or split by API tag with `--split` +- **Config file support** — `apigen.config.ts` with auto-discovery or `--config` flag +- **Interactive wizard** — run without flags to be guided through setup +- **allOf composition** — schema merging for specs using `allOf` +- **Dry-run mode** — preview generated files with `--dry-run` before writing ## Install @@ -49,7 +53,17 @@ npm install -D apigen-tanstack ## Usage ```bash +# From a local file npx apigen-tanstack generate --input ./openapi.yaml --output ./src/api/generated + +# From a config file +npx apigen-tanstack generate --config apigen.config.ts + +# Interactive mode (omit flags to be guided through setup) +npx apigen-tanstack generate + +# Preview without writing +npx apigen-tanstack generate -i ./openapi.yaml --dry-run ``` ## Generated Files diff --git a/docs/api-reference.md b/docs/api-reference.md index 27bccdf..cf29544 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -11,12 +11,14 @@ Creates a fully resolved configuration object. This is the recommended way to de `defineConfig` delegates to `resolveConfig` internally -- it exists as a named entrypoint so config files read naturally. ```ts -import { defineConfig } from 'apigen' +import { defineConfig } from 'apigen-tanstack' export default defineConfig({ input: './openapi.yaml', output: './src/api/generated', mock: true, + split: false, + baseURL: 'https://api.example.com', }) ``` @@ -35,11 +37,12 @@ export default defineConfig({ Resolves a partial `ConfigInput` into a complete `Config` by applying default values. Useful when you build config objects programmatically rather than through a config file. ```ts -import { resolveConfig } from 'apigen' +import { resolveConfig } from 'apigen-tanstack' const config = resolveConfig({ input: './spec.yaml' }) // config.output => './src/api/generated' // config.mock => true +// config.split => false ``` **Parameters** @@ -56,23 +59,29 @@ const config = resolveConfig({ input: './spec.yaml' }) ### `Config` -The fully resolved configuration object. Every field is required. +The fully resolved configuration object. Required fields are always present; optional fields may be `undefined`. -| Field | Type | Description | -|----------|-----------|--------------------------------------------------------------------| -| `input` | `string` | Path to the OpenAPI or Swagger spec file (JSON or YAML). | -| `output` | `string` | Directory where generated files are written. | -| `mock` | `boolean` | Whether to generate mock data alongside types and hooks. | +| Field | Type | Description | +|----------------------|-----------|--------------------------------------------------------------------| +| `input` | `string` | Path to the OpenAPI or Swagger spec file (JSON or YAML). | +| `output` | `string` | Directory where generated files are written. | +| `mock` | `boolean` | Whether to generate mock data alongside types and hooks. | +| `split` | `boolean` | Whether to split output into per-tag feature folders. | +| `baseURL` | `string?` | Base URL prefix for all generated fetch paths. | +| `apiFetchImportPath` | `string?` | Custom import path for the apiFetch function. | ### `ConfigInput` The input type accepted by `defineConfig` and `resolveConfig`. Only `input` is required; all other fields are optional and fall back to defaults. -| Field | Type | Required | Default | Description | -|----------|-----------|----------|--------------------------|--------------------------------------------------| -| `input` | `string` | Yes | -- | Path to the OpenAPI or Swagger spec file. | -| `output` | `string` | No | `'./src/api/generated'` | Output directory for generated files. | -| `mock` | `boolean` | No | `true` | Generate mock data files. | +| Field | Type | Required | Default | Description | +|----------------------|-----------|----------|--------------------------|--------------------------------------------------| +| `input` | `string` | Yes | -- | Path to the OpenAPI or Swagger spec file. | +| `output` | `string` | No | `'./src/api/generated'` | Output directory for generated files. | +| `mock` | `boolean` | No | `true` | Generate mock data files. | +| `split` | `boolean` | No | `false` | Split output into per-tag feature folders. | +| `baseURL` | `string` | No | *(none)* | Base URL prefix for all fetch paths. | +| `apiFetchImportPath` | `string` | No | *(none)* | Custom import path for apiFetch function. | --- @@ -96,8 +105,13 @@ apigen generate -i ./openapi.yaml | `-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. | +| `-c, --config ` | No | *(auto-searches)* | Path to config file. Auto-searches for `apigen.config.ts`/`.js` when omitted. | +| `--base-url ` | No | *(none)* | Base URL prefix for all generated fetch paths. | +| `--dry-run` | No | (disabled) | Preview generated files with sizes without writing. In TTY, prompts to proceed. | -> **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`). +> **Interactive mode:** When `-i` is omitted and no config file is found, apigen runs an interactive wizard: choose spec source (file, URL, or auto-discover), configure output/mock/split/baseURL options, and optionally save as `apigen.config.ts`. + +> **Config file priority:** CLI flags override config file values. Config file values override defaults. **Examples** diff --git a/docs/architecture.md b/docs/architecture.md index 901249b..bb6396f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -218,6 +218,7 @@ function generateSomething(ir: IR): string { | Generator | File | Signature | Description | |---|---|---|---| | `generateTypes` | `generators/types.ts` | `(ir: IR) => string` | Schema interfaces + param interfaces | +| `generateApiFetch` | `generators/api-fetch.ts` | `(options?) => string` | Shared apiFetch helper (used in split mode) | | `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) | @@ -263,9 +264,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, options?: { mock?: boolean; split?: boolean }): void` +**Entry point:** `writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean; baseURL?: string; apiFetchImportPath?: string; dryRun?: boolean }): FileInfo[] | void` -When `mock` is `false`, mocks and provider files are skipped. When `split` is `true`, output is organized into per-tag feature folders. +When `mock` is `false`, mocks and provider files are skipped. When `split` is `true`, output is organized into per-tag feature folders. When `dryRun` is `true`, returns an array of `FileInfo` objects (path + size) without writing. The `baseURL` and `apiFetchImportPath` options are passed through to the hooks and api-fetch generators. The writer is the orchestrator. It: @@ -341,7 +342,7 @@ The key constraint is that generators must be **pure functions**: `IR` in, `stri The CLI ties everything together: ``` -apigen generate -i [-o ] +apigen generate -i [-o ] [--config ] [--base-url ] [--dry-run] [--split] [--no-mock] ``` | Flag | Default | Description | @@ -350,17 +351,37 @@ apigen generate -i [-o ] | `-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 | +| `-c, --config ` | *(auto-searches)* | Path to config file | +| `--base-url ` | *(none)* | Base URL prefix for fetch paths | +| `--dry-run` | disabled | Preview files without writing | -Internally, it runs the pipeline in sequence: +### Config file loading + +The CLI loads configuration in this priority order: + +1. **Explicit `--config` flag** — loads the specified file +2. **Auto-search** — looks for `apigen.config.ts` or `apigen.config.js` in the current directory +3. **CLI flags** — override any config file values +4. **Interactive wizard** — runs when no config file and no `-i` flag are provided + +Config files are loaded via dynamic `import()` and must export a `ConfigInput` object (or use `defineConfig` for type safety). + +### Pipeline + +Internally, the CLI runs the pipeline in sequence: ```ts -const inputValue = options.input ?? (await promptForInput()) // Interactive if -i omitted +const fileConfig = await loadConfigFile(configPath) // Load config file (if found) +const config = resolveConfig({ ...fileConfig, ...cliFlags }) // Merge with CLI overrides +const inputValue = config.input || (await promptForInput()) // Interactive if no input 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 +writeGeneratedFiles(ir, outputPath, { mock, split, baseURL, apiFetchImportPath, dryRun }) // Stage 3+4 ``` -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`. +When no config file is found and `-i` is omitted, the full interactive wizard runs: `promptForInput()` for spec source, then `promptForConfig()` for output/mock/split/baseURL options, with an option to save as `apigen.config.ts`. + +The `--dry-run` flag invokes `collectFileInfo()` to calculate output file sizes without writing, then displays a preview. In TTY mode, the user is prompted to confirm before writing. The CLI logs the number of operations and schemas found, and the output directory path. @@ -390,15 +411,21 @@ For programmatic use, apigen exports `defineConfig` and `resolveConfig`: ```ts interface ConfigInput { - input: string // required: path or URL to spec - output?: string // default: './src/api/generated' - mock?: boolean // default: true + input: string // required: path or URL to spec + output?: string // default: './src/api/generated' + mock?: boolean // default: true + split?: boolean // default: false + baseURL?: string // prefix for all fetch paths + apiFetchImportPath?: string // custom import path for apiFetch } interface Config { input: string output: string mock: boolean + split: boolean + baseURL?: string + apiFetchImportPath?: string } ``` diff --git a/docs/configuration.md b/docs/configuration.md index ebf8421..4353068 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,30 +8,40 @@ Use `defineConfig` in a TypeScript config file to get type-checked configuration ```ts // apigen.config.ts -import { defineConfig } from 'apigen' +import { defineConfig } from 'apigen-tanstack' export default defineConfig({ input: './specs/petstore.yaml', output: './src/api/generated', mock: true, + split: false, + baseURL: 'https://api.example.com', }) ``` `defineConfig` accepts a `ConfigInput` object and returns a fully resolved `Config` with defaults applied. It is a pure helper for type safety -- it does not read files or trigger generation. +The CLI auto-searches for `apigen.config.ts` or `apigen.config.js` in the current directory when no `--config` flag is provided. + ### Type signature ```ts interface ConfigInput { - input: string // required - output?: string // optional, defaults to './src/api/generated' - mock?: boolean // optional, defaults to true + input: string // required + output?: string // optional, defaults to './src/api/generated' + mock?: boolean // optional, defaults to true + split?: boolean // optional, defaults to false + baseURL?: string // optional, prefix for all fetch paths + apiFetchImportPath?: string // optional, custom import path for apiFetch } interface Config { input: string output: string mock: boolean + split: boolean + baseURL?: string + apiFetchImportPath?: string } function defineConfig(config: ConfigInput): Config @@ -89,6 +99,54 @@ defineConfig({ > **Note:** When `mock` is `false`, the `mocks.ts` and `test-mode-provider.tsx` files are not generated, and hooks do not include test mode logic. +### `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`. + +| | | +|---|---| +| **Type** | `boolean` | +| **Default** | `false` | + +```ts +defineConfig({ + input: './openapi.yaml', + split: true, +}) +``` + +### `baseURL` + +Base URL prefix for all generated fetch paths. When set, the generated `apiFetch` helper prepends this URL to every request path. + +| | | +|---|---| +| **Type** | `string` | +| **Default** | *(none — paths are relative)* | + +```ts +defineConfig({ + input: './openapi.yaml', + baseURL: 'https://api.example.com', +}) +``` + +### `apiFetchImportPath` + +Custom import path for the `apiFetch` function. Use this when you have your own fetch wrapper and want generated hooks to import from it instead of using the built-in one. + +| | | +|---|---| +| **Type** | `string` | +| **Default** | *(none — uses built-in apiFetch)* | + +```ts +defineConfig({ + input: './openapi.yaml', + apiFetchImportPath: '../lib/api-client', +}) +``` + ## CLI Flags ```bash @@ -128,12 +186,38 @@ Split generated output into per-tag feature folders. Each tag gets its own direc npx apigen generate -i ./openapi.yaml --split ``` +### `--config` / `-c` + +Path to a config file. When omitted, the CLI searches for `apigen.config.ts` or `apigen.config.js` in the current directory. + +```bash +npx apigen generate --config ./config/apigen.config.ts +npx apigen generate -c apigen.config.ts +``` + +### `--base-url` + +Base URL prefix for all generated fetch paths. Overrides the `baseURL` config option. + +```bash +npx apigen generate -i ./openapi.yaml --base-url https://api.example.com +``` + +### `--dry-run` + +Preview the files that would be generated (with sizes) without writing anything. In a TTY, you are prompted to proceed after the preview. In non-TTY (CI), it prints and exits. + +```bash +npx apigen generate -i ./openapi.yaml --dry-run +``` + ### Full example ```bash npx apigen generate \ --input ./specs/petstore.yaml \ --output ./src/api \ + --base-url https://api.example.com \ --no-mock \ --split ``` @@ -173,3 +257,7 @@ The output directory contains these files (mocks and provider are omitted when ` | `output` | `--output` / `-o` | `./src/api/generated` | | `mock` | `--no-mock` to disable | `true` | | `split` | `--split` | `false` | +| `baseURL` | `--base-url` | *(none)* | +| `apiFetchImportPath` | *(config only)* | *(none)* | +| — | `--config` / `-c` | *(auto-searches for apigen.config.ts)* | +| — | `--dry-run` | *(disabled)* | diff --git a/docs/contributing.md b/docs/contributing.md index 0268f1d..5bcefb4 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -47,9 +47,14 @@ tests/ hooks.test.ts mocks.test.ts provider.test.ts + api-fetch.test.ts + index-file.test.ts fixtures/ petstore-oas3.yaml petstore-swagger2.yaml + inline-schemas.yaml + tagged-api.yaml + allof-composition.yaml ``` ## Scripts diff --git a/docs/getting-started.md b/docs/getting-started.md index fa22d76..1978e12 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,13 +27,37 @@ 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) +# From a config file (auto-searches for apigen.config.ts if --config is omitted) +npx apigen generate --config apigen.config.ts + +# Interactive mode (omit flags to be guided through setup) npx apigen generate + +# Preview without writing +npx apigen generate -i ./openapi.yaml --dry-run ``` 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. +When `-i` is omitted and no config file is found, an interactive wizard guides you through: local file path, direct URL, or auto-discover from a base URL — then prompts for output directory, mock/split options, and optionally saves your choices as `apigen.config.ts`. + +### Config file + +Create an `apigen.config.ts` in your project root for repeatable generation: + +```ts +import { defineConfig } from 'apigen-tanstack' + +export default defineConfig({ + input: './specs/petstore.yaml', + output: './src/api/generated', + mock: true, + split: false, + baseURL: 'https://api.example.com', +}) +``` + +The CLI auto-discovers this file. Use `--config ` to point to a different location. CLI flags override config file values. ## Generated Output Structure @@ -132,8 +156,9 @@ When `enabled` is `true`, every generated hook returns mock data from `mocks.ts` - **OpenAPI 3.x** (YAML or JSON) - **Swagger 2.0** (automatically converted to OpenAPI 3 via `swagger2openapi`) +- **allOf composition** — schemas using `allOf` are merged into flat interfaces -Specs with `$ref` references are bundled and resolved automatically via `@redocly/openapi-core`. +Specs with `$ref` references (including circular references) are bundled and resolved automatically via `@redocly/openapi-core`. ## Next Steps diff --git a/docs/plans/2026-02-26-tier1-cli-improvements-design.md b/docs/plans/2026-02-26-tier1-cli-improvements-design.md new file mode 100644 index 0000000..208a33c --- /dev/null +++ b/docs/plans/2026-02-26-tier1-cli-improvements-design.md @@ -0,0 +1,260 @@ +# Tier 1 CLI Improvements Design + +**Date**: 2026-02-26 +**Version target**: v0.3.0 +**Breaking changes**: None — all features opt-in + +--- + +## Scope + +8 items — 6 from the CLI audit Tier 1 recommendations + 2 interactive enhancements: + +1. Config file loading (`--config` flag + auto-search) +2. Extended ConfigInput (`split`, `baseURL`, `apiFetchImportPath`) +3. `--dry-run` flag +4. Improved error messages +5. Circular reference detection +6. allOf composition support +7. Interactive config init wizard (using `@inquirer/prompts`) +8. Dry-run confirmation prompt + +--- + +## 1. IR Hardening + +### 1a. Circular Reference Detection + +**File**: `src/ir.ts` + +Track visited schema names during `extractIR()` using a `Set`. When processing component schemas, if a `$ref` resolves to a schema already in the visiting set, emit the property with `ref` pointing to that schema name instead of recursing. This naturally breaks the cycle since generators already handle `ref` properties. + +Emit `console.warn("Circular reference detected: ${schemaName} → ${refName}, using type reference")`. + +No generator changes needed. + +### 1b. allOf Composition + +**File**: `src/ir.ts` + +Add `resolveAllOf(allOfArray, schemasDef)` that merges properties from all variants: + +- For `$ref` variants: look up referenced schema in `schemasDef`, collect its properties +- For inline variants: collect properties directly +- Merge all properties (later variants override on name collision) +- Union all `required` arrays + +Wire into `extractIR()` at: +- Top-level schema loop (when `schemaDef.allOf` exists) +- `extractInlineSchema()` (when inline schemas use allOf) + +Property merging only — no nested allOf-within-allOf in this release. + +--- + +## 2. Config Expansion + +### 2a. Extended Types + +**File**: `src/config.ts` + +```typescript +interface ConfigInput { + input: string + output?: string // default: './src/api/generated' + mock?: boolean // default: true + split?: boolean // NEW — default: false + baseURL?: string // NEW — no default + apiFetchImportPath?: string // NEW — no default +} + +interface Config { + input: string + output: string + mock: boolean + split: boolean // NEW + baseURL?: string // NEW + apiFetchImportPath?: string // NEW +} +``` + +### 2b. Pipeline Wiring + +**`cli.ts`**: Pass full `Config` to `writeGeneratedFiles()`. + +**`writer.ts`**: Accept `Config` (or equivalent options) and pass `baseURL` + `apiFetchImportPath` to generators. + +**`generators/hooks.ts`**: When `baseURL` is set, generated inline `apiFetch` uses `` `${baseURL}${path}` ``. The `apiFetchImportPath` option is already supported — just needs to flow from config. + +**`generators/api-fetch.ts`**: Accept optional `baseURL`, bake into generated function. + +### 2c. Config File Loader + +**File**: `src/cli.ts` + +```typescript +async function loadConfigFile(configPath: string): Promise { + const resolved = resolve(configPath) + const module = await import(pathToFileURL(resolved).toString()) + return module.default ?? module +} + +async function findConfigFile(): Promise { + for (const name of ['apigen.config.ts', 'apigen.config.js']) { + if (existsSync(resolve(name))) return resolve(name) + } + return null +} +``` + +Format: TS/JS only via `import()`. No JSON config. +Search order: `apigen.config.ts`, `apigen.config.js`. +CLI flags override config file values. + +### 2d. Interactive Config Init Wizard + +**File**: `src/cli.ts` + +**Trigger**: User runs `apigen generate` without `-i` and no config file exists. + +After the existing spec source prompt (`promptForInput()`) completes, present config questions: + +``` +? Output directory: (./src/api/generated) +? Generate mock data? (Y/n) +? Split output by API tags? (y/N) +? Base URL for API calls: (leave empty for relative paths) +? Save as apigen.config.ts? (Y/n) +``` + +Prompt types: +- `input()` — output directory and base URL (with defaults) +- `confirm()` — mock, split, and save config + +If user confirms save, write `apigen.config.ts`: + +```typescript +import { defineConfig } from 'apigen-tanstack' + +export default defineConfig({ + input: './openapi.yaml', + output: './src/api/generated', + mock: true, + split: false, + baseURL: 'https://api.example.com', +}) +``` + +Helper function: + +```typescript +function writeConfigFile(config: ConfigInput): void { + const lines = [ + `import { defineConfig } from 'apigen-tanstack'`, + ``, + `export default defineConfig(${JSON.stringify(config, null, 2)})`, + ``, + ] + writeFileSync('apigen.config.ts', lines.join('\n'), 'utf8') +} +``` + +**Skip conditions**: When config file already exists OR when `-i` is provided, skip the wizard entirely. + +--- + +## 3. CLI Flags + +### New flags on `generate` command + +``` +-c, --config Path to config file (auto-searches if omitted) +--dry-run Preview without writing files +--base-url Prefix for all fetch paths +``` + +### `--dry-run` behavior + +Run full pipeline (load, IR, generate) but don't write. Print summary: + +``` +Dry run — files that would be generated: + ./src/api/generated/types.ts (2.1 KB, 12 interfaces) + ./src/api/generated/hooks.ts (3.4 KB, 8 hooks) + ./src/api/generated/mocks.ts (1.8 KB, 12 mocks) + ./src/api/generated/test-mode-provider.tsx (0.5 KB) + ./src/api/generated/index.ts (0.2 KB) +``` + +Implementation: Add `dryRun` option to `writeGeneratedFiles`. When true, generate content strings, return metadata (paths + sizes), CLI prints summary. + +### `--dry-run` interactive confirmation + +After the preview, if stdin is a TTY, prompt with `confirm()`: + +``` +? Proceed with generation? (Y/n) +``` + +- **Yes**: Run the actual write. +- **No**: Exit with `console.log('Cancelled.')` and exit code 0. +- **Non-TTY** (piped stdin / CI): Skip the confirm, just print preview and exit. + +```typescript +if (options.dryRun) { + printDryRunSummary(files) + if (!process.stdin.isTTY) return + const proceed = await confirm({ message: 'Proceed with generation?' }) + if (!proceed) { console.log('Cancelled.'); return } + // fall through to actual write +} +``` + +--- + +## 4. Error Messages + +Improve at 3 locations: + +### `loader.ts` + +- File not found: `"Cannot find spec file: ./openapi.yaml. Check the path and try again."` +- Parse failure: `"Failed to parse ./spec.yaml: [error]. Ensure the file is valid YAML or JSON."` + +### `ir.ts` + +- No paths: `console.warn("Warning: Spec has no 'paths' — 0 operations extracted.")` +- Empty schema: `console.warn("Warning: Schema 'User' has no properties, skipping.")` + +Approach: Better context on existing throws + `console.warn()` for non-fatal issues. No new error types. + +--- + +## Implementation Order (Bottom-Up) + +1. IR hardening (circular refs + allOf) — self-contained in `ir.ts` +2. Config expansion — types + resolveConfig + wire through writer/generators +3. Config file loader — new functions in `cli.ts` +4. CLI flags (`--config`, `--dry-run`, `--base-url`) — tie everything together +5. Error messages — improve across loader + ir +6. Interactive config init wizard — `promptForConfig()` in `cli.ts` +7. Dry-run confirmation — `confirm()` after preview with TTY check + +Each step independently testable with TDD. + +--- + +## Files Changed + +| File | Changes | +|------|---------| +| `src/ir.ts` | Circular ref detection, allOf resolution, warning messages | +| `src/config.ts` | 3 new fields in ConfigInput/Config | +| `src/cli.ts` | Config loader, auto-search, --config/--dry-run/--base-url flags, config init wizard, dry-run confirmation | +| `src/writer.ts` | Accept Config, pass baseURL/apiFetchImportPath to generators, dry-run support | +| `src/generators/hooks.ts` | Use baseURL in generated apiFetch | +| `src/generators/api-fetch.ts` | Accept baseURL param | +| `src/loader.ts` | Better error messages | +| `tests/ir.test.ts` | Circular ref + allOf tests | +| `tests/config.test.ts` | New config fields tests | +| `tests/e2e.test.ts` | Config file + dry-run integration tests | diff --git a/docs/plans/2026-02-26-tier1-cli-improvements.md b/docs/plans/2026-02-26-tier1-cli-improvements.md new file mode 100644 index 0000000..34068f7 --- /dev/null +++ b/docs/plans/2026-02-26-tier1-cli-improvements.md @@ -0,0 +1,1244 @@ +# Tier 1 CLI Improvements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add config file loading, extended config options (split/baseURL/apiFetchImportPath), --dry-run, better error messages, circular reference detection, allOf composition, an interactive config init wizard, and dry-run confirmation to make apigen CI/CD-ready and beginner-friendly. + +**Architecture:** Bottom-up approach — harden IR first (circular refs + allOf), then expand config types, then wire through generators/writer, then add CLI flags, then layer interactive prompts on top. Each task is independently testable. All changes are non-breaking and opt-in. + +**Tech Stack:** TypeScript, Vitest, Commander.js, @inquirer/prompts (select, input, confirm), Node.js dynamic `import()` for config file loading. + +**Design doc:** `docs/plans/2026-02-26-tier1-cli-improvements-design.md` + +--- + +### Task 1: Circular Reference Detection in IR + +**Files:** +- Modify: `src/ir.ts:293-312` (top-level schema extraction loop) +- Test: `tests/ir.test.ts` + +**Step 1: Write the failing test** + +Add to `tests/ir.test.ts`: + +```typescript +it('detects circular references and breaks the cycle', () => { + const spec = { + paths: {}, + components: { + schemas: { + User: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + manager: { $ref: '#/components/schemas/User' }, + }, + }, + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/components/schemas/Node' } }, + }, + }, + }, + }, + } + const ir = extractIR(spec as Record) + + // Should complete without infinite loop + expect(ir.schemas).toHaveLength(2) + + const user = ir.schemas.find(s => s.name === 'User') + expect(user).toBeDefined() + expect(user!.properties.find(p => p.name === 'manager')!.ref).toBe('#/components/schemas/User') + + const node = ir.schemas.find(s => s.name === 'Node') + expect(node).toBeDefined() + const children = node!.properties.find(p => p.name === 'children')! + expect(children.isArray).toBe(true) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test tests/ir.test.ts -- -t "detects circular references"` +Expected: Test hangs or times out due to infinite recursion. + +**Step 3: Write minimal implementation** + +In `src/ir.ts`, the top-level schema loop (lines 293-312) does NOT recurse into `$ref` — it only reads `properties` directly. So circular refs at the top-level schema extraction already work (they just emit `ref` strings). The real risk is in inline schema extraction via `extractInlineSchema` when a request body or response has inline schemas that reference component schemas. + +The fix: In the schema extraction loop, add a `visited` set and pass it through. When extracting properties, if a `$ref` references a schema that's currently being visited, keep the `ref` string (which generators already handle) instead of trying to inline it. + +Modify `extractIR()` at the schema loop section: + +```typescript +// Add at top of extractIR, before the schema loop: +const visiting = new Set() + +// In the schema loop, wrap each schema processing: +for (const [name, schemaDef] of Object.entries(schemasDef)) { + visiting.add(name) + // ... existing property extraction ... + visiting.delete(name) + schemas.push({ name, properties, required }) +} +``` + +The current code already handles `$ref` by keeping it as a string — it does NOT inline referenced schemas. So circular refs at the component level already work. The test should pass without code changes. If it does hang, add the visited set guard. + +**Step 4: Run test to verify it passes** + +Run: `bun test tests/ir.test.ts -- -t "detects circular references"` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `bun test` +Expected: All existing tests pass + +**Step 6: Commit** + +``` +feat(ir): add circular reference detection test +``` + +--- + +### Task 2: allOf Composition Support + +**Files:** +- Modify: `src/ir.ts` (add `resolveAllOf` function, wire into schema extraction) +- Create: `tests/fixtures/allof-composition.yaml` +- Test: `tests/ir.test.ts` + +**Step 1: Create the allOf test fixture** + +Create `tests/fixtures/allof-composition.yaml`: + +```yaml +openapi: "3.0.3" +info: + title: allOf Test + version: "1.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + post: + operationId: createUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserBody" + responses: + "201": + description: created + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + BaseEntity: + type: object + required: + - id + properties: + id: + type: string + createdAt: + type: string + format: date-time + User: + allOf: + - $ref: "#/components/schemas/BaseEntity" + - type: object + required: + - name + properties: + name: + type: string + email: + type: string + CreateUserBody: + type: object + required: + - name + properties: + name: + type: string + email: + type: string +``` + +**Step 2: Write the failing tests** + +Add to `tests/ir.test.ts`: + +```typescript +it('resolves allOf by merging properties from all variants', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/allof-composition.yaml')) + const ir = extractIR(spec) + + const user = ir.schemas.find(s => s.name === 'User') + expect(user).toBeDefined() + // Should have merged properties from BaseEntity + inline + expect(user!.properties).toHaveLength(4) + expect(user!.properties.find(p => p.name === 'id')).toBeDefined() + expect(user!.properties.find(p => p.name === 'createdAt')).toBeDefined() + expect(user!.properties.find(p => p.name === 'name')).toBeDefined() + expect(user!.properties.find(p => p.name === 'email')).toBeDefined() + // Required should merge from both + expect(user!.required).toContain('id') + expect(user!.required).toContain('name') +}) + +it('resolves allOf with inline-only variants (no $ref)', () => { + const spec = { + paths: {}, + components: { + schemas: { + Merged: { + allOf: [ + { type: 'object', required: ['a'], properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }, + }, + }, + } + const ir = extractIR(spec as Record) + const merged = ir.schemas.find(s => s.name === 'Merged') + expect(merged).toBeDefined() + expect(merged!.properties).toHaveLength(2) + expect(merged!.properties.find(p => p.name === 'a')!.type).toBe('string') + expect(merged!.properties.find(p => p.name === 'b')!.type).toBe('number') + expect(merged!.required).toContain('a') +}) +``` + +**Step 3: Run tests to verify they fail** + +Run: `bun test tests/ir.test.ts -- -t "resolves allOf"` +Expected: FAIL — allOf schemas currently generate 0 properties + +**Step 4: Implement resolveAllOf** + +Add to `src/ir.ts` before `extractIR`: + +```typescript +function resolveAllOf( + allOfArray: Record[], + schemasDef: Record>, +): { properties: Record>; required: string[] } { + const mergedProps: Record> = {} + const mergedRequired: string[] = [] + + for (const variant of allOfArray) { + let variantSchema = variant + + // Resolve $ref to component schema + if (variant.$ref && typeof variant.$ref === 'string') { + const refName = (variant.$ref as string).split('/').pop() + if (refName && schemasDef[refName]) { + variantSchema = schemasDef[refName] + // If the referenced schema itself uses allOf, resolve it recursively (one level) + if (Array.isArray(variantSchema.allOf)) { + const resolved = resolveAllOf(variantSchema.allOf as Record[], schemasDef) + Object.assign(mergedProps, resolved.properties) + mergedRequired.push(...resolved.required) + continue + } + } + } + + const props = (variantSchema.properties ?? {}) as Record> + const req = (variantSchema.required ?? []) as string[] + Object.assign(mergedProps, props) + mergedRequired.push(...req) + } + + return { properties: mergedProps, required: mergedRequired } +} +``` + +In `extractIR`, modify the top-level schema loop to detect allOf: + +```typescript +for (const [name, schemaDef] of Object.entries(schemasDef)) { + let props: Record> + let required: string[] + + if (Array.isArray(schemaDef.allOf)) { + const resolved = resolveAllOf(schemaDef.allOf as Record[], schemasDef) + props = resolved.properties + required = resolved.required + } else { + props = (schemaDef.properties ?? {}) as Record> + required = (schemaDef.required ?? []) as string[] + } + + const properties: IRProperty[] = Object.entries(props).map(([propName, propSchema]) => { + // ... existing property mapping logic (lines 297-309) ... + }) + + schemas.push({ name, properties, required }) +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `bun test tests/ir.test.ts -- -t "resolves allOf"` +Expected: PASS + +**Step 6: Run full test suite** + +Run: `bun test` +Expected: All tests pass (existing tests unaffected) + +**Step 7: Commit** + +``` +feat(ir): support allOf composition via property merging +``` + +--- + +### Task 3: Extend ConfigInput with split, baseURL, apiFetchImportPath + +**Files:** +- Modify: `src/config.ts` +- Test: `tests/config.test.ts` + +**Step 1: Write failing tests** + +Add to `tests/config.test.ts`: + +```typescript +it('resolveConfig applies split default to false', () => { + const config = resolveConfig({ input: './spec.yaml' }) + expect(config.split).toBe(false) +}) + +it('resolveConfig passes through split, baseURL, apiFetchImportPath', () => { + const config = resolveConfig({ + input: './spec.yaml', + split: true, + baseURL: 'https://api.example.com', + apiFetchImportPath: './lib/api-client', + }) + expect(config.split).toBe(true) + expect(config.baseURL).toBe('https://api.example.com') + expect(config.apiFetchImportPath).toBe('./lib/api-client') +}) + +it('resolveConfig leaves baseURL and apiFetchImportPath undefined when not set', () => { + const config = resolveConfig({ input: './spec.yaml' }) + expect(config.baseURL).toBeUndefined() + expect(config.apiFetchImportPath).toBeUndefined() +}) +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test tests/config.test.ts` +Expected: FAIL — `split`, `baseURL`, `apiFetchImportPath` not on Config type + +**Step 3: Implement** + +Update `src/config.ts`: + +```typescript +interface Config { + input: string + output: string + mock: boolean + split: boolean + baseURL?: string + apiFetchImportPath?: string +} + +interface ConfigInput { + input: string + output?: string + mock?: boolean + split?: boolean + baseURL?: string + apiFetchImportPath?: string +} + +function resolveConfig(input: ConfigInput): Config { + return { + input: input.input, + output: input.output ?? './src/api/generated', + mock: input.mock ?? true, + split: input.split ?? false, + baseURL: input.baseURL, + apiFetchImportPath: input.apiFetchImportPath, + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `bun test tests/config.test.ts` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `bun test` +Expected: All tests pass + +**Step 6: Commit** + +``` +feat(config): add split, baseURL, apiFetchImportPath to ConfigInput +``` + +--- + +### Task 4: Wire baseURL Through Generators + +**Files:** +- Modify: `src/generators/api-fetch.ts` +- Modify: `src/generators/hooks.ts:215-226` (inline apiFetch generation) +- Test: `tests/generators/api-fetch.test.ts` +- Test: `tests/generators/hooks.test.ts` + +**Step 1: Write failing tests for api-fetch** + +Add to `tests/generators/api-fetch.test.ts`: + +```typescript +it('generates apiFetch with baseURL when provided', () => { + const output = generateApiFetch({ baseURL: 'https://api.example.com' }) + expect(output).toContain('https://api.example.com') + expect(output).toContain('`https://api.example.com${path}`') +}) + +it('generates apiFetch without baseURL when not provided', () => { + const output = generateApiFetch() + expect(output).not.toContain('https://') + expect(output).toContain('fetch(path') +}) +``` + +**Step 2: Write failing tests for hooks with baseURL** + +Add to `tests/generators/hooks.test.ts`: + +```typescript +it('generates inline apiFetch with baseURL when provided', async () => { + const spec = await loadSpec(resolve(__dirname, '../fixtures/petstore-oas3.yaml')) + const ir = extractIR(spec) + const output = generateHooks(ir, { mock: false, baseURL: 'https://api.example.com' }) + + expect(output).toContain('https://api.example.com') + expect(output).toContain('`https://api.example.com${path}`') +}) +``` + +**Step 3: Run tests to verify they fail** + +Run: `bun test tests/generators/api-fetch.test.ts tests/generators/hooks.test.ts` +Expected: FAIL — `generateApiFetch` and `generateHooks` don't accept `baseURL` + +**Step 4: Implement baseURL in api-fetch.ts** + +Update `src/generators/api-fetch.ts`: + +```typescript +function generateApiFetch(options?: { baseURL?: string }): string { + const baseURL = options?.baseURL + const lines: string[] = [] + lines.push('/* eslint-disable */') + lines.push('/* This file is auto-generated by apigen. Do not edit. */') + lines.push('') + lines.push('export function apiFetch(path: string, init?: RequestInit): Promise {') + if (baseURL) { + lines.push(` return fetch(\`${baseURL}\${path}\`, {`) + } else { + lines.push(' return fetch(path, {') + } + lines.push(" headers: { 'Content-Type': 'application/json' },") + lines.push(' ...init,') + lines.push(' }).then(res => {') + lines.push(' if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)') + lines.push(' return res.json() as Promise') + lines.push(' })') + lines.push('}') + lines.push('') + return lines.join('\n') +} + +export { generateApiFetch } +``` + +**Step 5: Implement baseURL in hooks.ts inline apiFetch** + +In `src/generators/hooks.ts`, update the function signature and inline apiFetch block: + +```typescript +function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: string; apiFetchImportPath?: string; baseURL?: string }): string { +``` + +Update the inline apiFetch generation (lines 215-226): + +```typescript +if (!apiFetchImportPath) { + const baseURL = options?.baseURL + parts.push(`function apiFetch(path: string, init?: RequestInit): Promise {`) + if (baseURL) { + parts.push(` return fetch(\`${baseURL}\${path}\`, {`) + } else { + parts.push(` return fetch(path, {`) + } + parts.push(` headers: { 'Content-Type': 'application/json' },`) + parts.push(` ...init,`) + parts.push(` }).then(res => {`) + parts.push(` if (!res.ok) throw new Error(\`\${res.status} \${res.statusText}\`)`) + parts.push(` return res.json() as Promise`) + parts.push(` })`) + parts.push(`}`) + parts.push('') +} +``` + +**Step 6: Run tests to verify they pass** + +Run: `bun test tests/generators/api-fetch.test.ts tests/generators/hooks.test.ts` +Expected: PASS + +**Step 7: Run full test suite** + +Run: `bun test` +Expected: All tests pass + +**Step 8: Commit** + +``` +feat(generators): wire baseURL into apiFetch and hooks generation +``` + +--- + +### Task 5: Wire Config Through Writer + +**Files:** +- Modify: `src/writer.ts:51-113` +- Test: `tests/writer.test.ts` + +**Step 1: Write failing test** + +Add to `tests/writer.test.ts`: + +```typescript +it('passes baseURL to generated hooks when provided', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/petstore-oas3.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-test-')) + + try { + writeGeneratedFiles(ir, outDir, { mock: true, baseURL: 'https://api.example.com' }) + + const hooks = readFileSync(join(outDir, 'hooks.ts'), 'utf8') + expect(hooks).toContain('https://api.example.com') + } finally { + rmSync(outDir, { recursive: true }) + } +}) + +it('passes baseURL to split mode api-fetch when provided', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/tagged-api.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-test-')) + + try { + writeGeneratedFiles(ir, outDir, { split: true, baseURL: 'https://api.example.com' }) + + const apiFetch = readFileSync(join(outDir, 'api-fetch.ts'), 'utf8') + expect(apiFetch).toContain('https://api.example.com') + } finally { + rmSync(outDir, { recursive: true }) + } +}) +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test tests/writer.test.ts` +Expected: FAIL — `writeGeneratedFiles` doesn't pass `baseURL` through + +**Step 3: Implement** + +Update `src/writer.ts` — add `baseURL` and `apiFetchImportPath` to the options type and pass through: + +```typescript +function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean; baseURL?: string; apiFetchImportPath?: string }): void { + const mock = options?.mock ?? true + const split = options?.split ?? false + const baseURL = options?.baseURL + const apiFetchImportPath = options?.apiFetchImportPath + + if (split) { + writeSplit(ir, outputDir, mock, { baseURL }) + } else { + writeFlat(ir, outputDir, mock, { baseURL, apiFetchImportPath }) + } +} +``` + +Update `writeFlat` to pass `baseURL` to `generateHooks`: + +```typescript +function writeFlat(ir: IR, outputDir: string, mock: boolean, opts?: { baseURL?: string; apiFetchImportPath?: string }): void { + mkdirSync(outputDir, { recursive: true }) + + writeFileSync(join(outputDir, 'types.ts'), generateTypes(ir), 'utf8') + writeFileSync(join(outputDir, 'hooks.ts'), generateHooks(ir, { mock, baseURL: opts?.baseURL, apiFetchImportPath: opts?.apiFetchImportPath }), 'utf8') + // ... rest unchanged +} +``` + +Update `writeSplit` to pass `baseURL` to `generateApiFetch` and `generateHooks`: + +```typescript +function writeSplit(ir: IR, outputDir: string, mock: boolean, opts?: { baseURL?: string }): void { + // ... + writeFileSync(join(outputDir, 'api-fetch.ts'), generateApiFetch({ baseURL: opts?.baseURL }), 'utf8') + // In per-tag loop, generateHooks already receives apiFetchImportPath for split mode + // baseURL not needed in per-tag hooks since api-fetch handles it + // ... +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `bun test tests/writer.test.ts` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `bun test` +Expected: All tests pass + +**Step 6: Commit** + +``` +feat(writer): pass baseURL and apiFetchImportPath through to generators +``` + +--- + +### Task 6: Config File Loading + +**Files:** +- Modify: `src/cli.ts` +- Test: `tests/cli.test.ts` (or integration test) + +**Step 1: Write the config file loader functions** + +Add to `src/cli.ts` (before the `program` setup): + +```typescript +import { existsSync } from 'fs' +import { pathToFileURL } from 'url' +import { resolveConfig } from './config' +import type { ConfigInput } from './config' + +async function loadConfigFile(configPath: string): Promise { + const resolved = resolve(configPath) + if (!existsSync(resolved)) { + throw new Error(`Config file not found: ${configPath}`) + } + const module = await import(pathToFileURL(resolved).toString()) + return module.default ?? module +} + +function findConfigFile(): string | null { + for (const name of ['apigen.config.ts', 'apigen.config.js']) { + if (existsSync(resolve(name))) return resolve(name) + } + return null +} +``` + +**Step 2: Wire config loading into the generate command** + +Update the `.action()` handler in `src/cli.ts`: + +```typescript +.option('-c, --config ', 'Path to config file (searches for apigen.config.ts by default)') +.option('--base-url ', 'Base URL prefix for all API fetch paths') +.action(async (options: { input?: string; output: string; mock: boolean; split?: boolean; config?: string; baseUrl?: string }) => { + // Load config file (explicit or auto-search) + let fileConfig: ConfigInput | null = null + if (options.config) { + fileConfig = await loadConfigFile(options.config) + } else { + const found = findConfigFile() + if (found) { + console.log(`Using config file: ${found}`) + fileConfig = await loadConfigFile(found) + } + } + + // Merge: CLI flags override config file + const config = resolveConfig({ + input: options.input ?? fileConfig?.input ?? '', + output: options.output !== './src/api/generated' ? options.output : (fileConfig?.output ?? options.output), + mock: options.mock !== undefined ? options.mock : fileConfig?.mock, + split: options.split ?? fileConfig?.split, + baseURL: options.baseUrl ?? fileConfig?.baseURL, + apiFetchImportPath: fileConfig?.apiFetchImportPath, + }) + + const inputValue = config.input || (await promptForInput()) + // ... rest of the pipeline using config ... +}) +``` + +**Step 3: Run full test suite** + +Run: `bun test` +Expected: All existing tests still pass + +**Step 4: Commit** + +``` +feat(cli): add config file loading with --config flag and auto-search +``` + +--- + +### Task 7: Interactive Config Init Wizard + +**Files:** +- Modify: `src/cli.ts` + +**Step 1: Add `promptForConfig()` function** + +Add to `src/cli.ts` after the existing `promptForInput()` function: + +```typescript +import { confirm } from '@inquirer/prompts' // add to existing import from '@inquirer/prompts' + +async function promptForConfig(inputValue: string): Promise { + const output = await input({ + message: 'Output directory:', + default: './src/api/generated', + }) + + const mock = await confirm({ + message: 'Generate mock data?', + default: true, + }) + + const split = await confirm({ + message: 'Split output by API tags?', + default: false, + }) + + const baseURL = await input({ + message: 'Base URL for API calls (leave empty for relative paths):', + }) + + const configInput: ConfigInput = { + input: inputValue, + output: output.trim(), + mock, + split, + ...(baseURL.trim() ? { baseURL: baseURL.trim() } : {}), + } + + const shouldSave = await confirm({ + message: 'Save as apigen.config.ts?', + default: true, + }) + + if (shouldSave) { + writeConfigFile(configInput) + console.log('Saved apigen.config.ts') + } + + return configInput +} +``` + +**Step 2: Add `writeConfigFile()` helper** + +Add to `src/cli.ts`: + +```typescript +function writeConfigFile(config: ConfigInput): void { + const lines = [ + `import { defineConfig } from 'apigen-tanstack'`, + ``, + `export default defineConfig(${JSON.stringify(config, null, 2)})`, + ``, + ] + writeFileSync('apigen.config.ts', lines.join('\n'), 'utf8') +} +``` + +**Step 3: Wire into the action handler** + +Update the action handler to call `promptForConfig()` when no config file exists and no `-i` was given: + +```typescript +.action(async (options) => { + // Load config file (explicit or auto-search) + let fileConfig: ConfigInput | null = null + if (options.config) { + fileConfig = await loadConfigFile(options.config) + } else { + const found = findConfigFile() + if (found) { + console.log(`Using config file: ${found}`) + fileConfig = await loadConfigFile(found) + } + } + + // If no config file and no -i flag, run interactive wizard + if (!fileConfig && !options.input) { + const inputValue = await promptForInput() + const wizardConfig = await promptForConfig(inputValue) + const config = resolveConfig(wizardConfig) + // ... proceed with pipeline using config + } else { + // Merge: CLI flags override config file + const config = resolveConfig({ + input: options.input ?? fileConfig?.input ?? '', + output: options.output !== './src/api/generated' ? options.output : (fileConfig?.output ?? options.output), + mock: options.mock !== undefined ? options.mock : fileConfig?.mock, + split: options.split ?? fileConfig?.split, + baseURL: options.baseUrl ?? fileConfig?.baseURL, + apiFetchImportPath: fileConfig?.apiFetchImportPath, + }) + const inputValue = config.input || (await promptForInput()) + // ... proceed with pipeline using config + inputValue + } +}) +``` + +**Step 4: Run typecheck** + +Run: `bun run typecheck` +Expected: No errors + +**Step 5: Run full test suite** + +Run: `bun test` +Expected: All existing tests still pass (they all provide `-i`, never hitting the wizard) + +**Step 6: Commit** + +``` +feat(cli): add interactive config init wizard with @inquirer/prompts +``` + +--- + +### Task 8: --dry-run Flag with Confirmation Prompt + +**Files:** +- Modify: `src/writer.ts` +- Modify: `src/cli.ts` +- Test: `tests/writer.test.ts` + +**Step 1: Write failing test** + +Add to `tests/writer.test.ts`: + +```typescript +it('returns file metadata without writing when dryRun is true', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/petstore-oas3.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-test-')) + + try { + const result = writeGeneratedFiles(ir, outDir, { mock: true, dryRun: true }) + + // Should return file info + expect(result).toBeDefined() + expect(result!.length).toBeGreaterThan(0) + expect(result!.some(f => f.path.endsWith('types.ts'))).toBe(true) + expect(result!.some(f => f.path.endsWith('hooks.ts'))).toBe(true) + expect(result!.every(f => f.size > 0)).toBe(true) + + // Should NOT have written any files + expect(existsSync(join(outDir, 'types.ts'))).toBe(false) + expect(existsSync(join(outDir, 'hooks.ts'))).toBe(false) + } finally { + rmSync(outDir, { recursive: true }) + } +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test tests/writer.test.ts -- -t "dryRun"` +Expected: FAIL — `dryRun` option doesn't exist + +**Step 3: Implement dry-run in writer** + +Update `src/writer.ts`: + +```typescript +interface FileInfo { + path: string + size: number +} + +function writeGeneratedFiles( + ir: IR, + outputDir: string, + options?: { mock?: boolean; split?: boolean; baseURL?: string; apiFetchImportPath?: string; dryRun?: boolean }, +): FileInfo[] | void { + const mock = options?.mock ?? true + const split = options?.split ?? false + const dryRun = options?.dryRun ?? false + const baseURL = options?.baseURL + const apiFetchImportPath = options?.apiFetchImportPath + + if (dryRun) { + return collectFileInfo(ir, outputDir, { mock, split, baseURL, apiFetchImportPath }) + } + + if (split) { + writeSplit(ir, outputDir, mock, { baseURL }) + } else { + writeFlat(ir, outputDir, mock, { baseURL, apiFetchImportPath }) + } +} +``` + +Add `collectFileInfo` function that generates content strings and returns metadata: + +```typescript +function collectFileInfo( + ir: IR, + outputDir: string, + opts: { mock: boolean; split: boolean; baseURL?: string; apiFetchImportPath?: string }, +): FileInfo[] { + const files: FileInfo[] = [] + + if (opts.split) { + // Similar to writeSplit but collect instead of write + const groups = groupOperationsByTag(ir.operations) + const tagSlugs = [...groups.keys()].sort() + + if (opts.mock) { + const content = generateProvider() + files.push({ path: join(outputDir, 'test-mode-provider.tsx'), size: Buffer.byteLength(content) }) + } + const apiFetchContent = generateApiFetch({ baseURL: opts.baseURL }) + files.push({ path: join(outputDir, 'api-fetch.ts'), size: Buffer.byteLength(apiFetchContent) }) + + for (const slug of tagSlugs) { + const ops = groups.get(slug)! + const subsetIR = buildSubsetIR(ops, ir.schemas) + const featureDir = join(outputDir, slug) + + files.push({ path: join(featureDir, 'types.ts'), size: Buffer.byteLength(generateTypes(subsetIR)) }) + files.push({ path: join(featureDir, 'hooks.ts'), size: Buffer.byteLength(generateHooks(subsetIR, { mock: opts.mock, providerImportPath: '../test-mode-provider', apiFetchImportPath: '../api-fetch' })) }) + if (opts.mock) { + files.push({ path: join(featureDir, 'mocks.ts'), size: Buffer.byteLength(generateMocks(subsetIR)) }) + } + files.push({ path: join(featureDir, 'index.ts'), size: Buffer.byteLength(generateIndexFile({ mock: opts.mock, includeProvider: false })) }) + } + + files.push({ path: join(outputDir, 'index.ts'), size: Buffer.byteLength(generateRootIndexFile(tagSlugs, { mock: opts.mock })) }) + } else { + files.push({ path: join(outputDir, 'types.ts'), size: Buffer.byteLength(generateTypes(ir)) }) + files.push({ path: join(outputDir, 'hooks.ts'), size: Buffer.byteLength(generateHooks(ir, { mock: opts.mock, baseURL: opts.baseURL, apiFetchImportPath: opts.apiFetchImportPath })) }) + if (opts.mock) { + files.push({ path: join(outputDir, 'mocks.ts'), size: Buffer.byteLength(generateMocks(ir)) }) + files.push({ path: join(outputDir, 'test-mode-provider.tsx'), size: Buffer.byteLength(generateProvider()) }) + } + files.push({ path: join(outputDir, 'index.ts'), size: Buffer.byteLength(generateIndexFile({ mock: opts.mock })) }) + } + + return files +} +``` + +**Step 4: Wire --dry-run into CLI with confirmation prompt** + +In `src/cli.ts`, add the flag: + +```typescript +.option('--dry-run', 'Preview files that would be generated without writing') +``` + +In the action handler, print the summary then prompt to confirm (TTY only): + +```typescript +if (options.dryRun) { + const files = writeGeneratedFiles(ir, outputPath, { ...config, dryRun: true }) as FileInfo[] + const totalSize = files.reduce((sum, f) => sum + f.size, 0) + + console.log('\nDry run — files that would be generated:\n') + for (const f of files) { + const sizeStr = f.size > 1024 ? `${(f.size / 1024).toFixed(1)} KB` : `${f.size} B` + console.log(` ${f.path} (${sizeStr})`) + } + const totalStr = totalSize > 1024 ? `${(totalSize / 1024).toFixed(1)} KB` : `${totalSize} B` + console.log(`\n Total: ${files.length} files, ${totalStr}\n`) + + // In non-TTY (CI), just print and exit + if (!process.stdin.isTTY) return + + // In TTY, ask to proceed + const proceed = await confirm({ message: 'Proceed with generation?' }) + if (!proceed) { + console.log('Cancelled.') + return + } + + // User said yes — do the actual write + writeGeneratedFiles(ir, outputPath, { ...config }) + console.log(`Generated files written to ${outputPath}`) + return +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `bun test tests/writer.test.ts` +Expected: PASS + +**Step 6: Run full test suite** + +Run: `bun test` +Expected: All tests pass + +**Step 7: Commit** + +``` +feat(cli): add --dry-run flag with interactive confirmation prompt +``` + +--- + +### Task 9: Improved Error Messages + +**Files:** +- Modify: `src/loader.ts:51-72` +- Modify: `src/ir.ts:206-215` +- Test: `tests/loader.test.ts` +- Test: `tests/ir.test.ts` + +**Step 1: Write failing tests for loader errors** + +Add to `tests/loader.test.ts`: + +```typescript +it('throws user-friendly error for file not found', async () => { + await expect(loadSpec('./nonexistent-spec.yaml')).rejects.toThrow('Cannot find spec file') +}) + +it('throws user-friendly error for unparseable file', async () => { + const tmpFile = join(tmpdir(), 'bad-spec-' + Date.now() + '.yaml') + writeFileSync(tmpFile, '{{invalid yaml content', 'utf8') + try { + await expect(loadSpec(tmpFile)).rejects.toThrow('Failed to parse') + } finally { + rmSync(tmpFile) + } +}) +``` + +Add needed imports at top of `tests/loader.test.ts`: + +```typescript +import { join } from 'path' +import { tmpdir } from 'os' +import { writeFileSync, rmSync } from 'fs' +``` + +**Step 2: Write failing tests for IR warnings** + +Add to `tests/ir.test.ts`: + +```typescript +import { vi } from 'vitest' + +it('warns when spec has no paths', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const spec = { components: { schemas: {} } } + const ir = extractIR(spec as Record) + expect(ir.operations).toHaveLength(0) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no paths')) + warnSpy.mockRestore() +}) +``` + +**Step 3: Run tests to verify they fail** + +Run: `bun test tests/loader.test.ts tests/ir.test.ts` +Expected: FAIL — errors are raw stack traces, no warning for empty paths + +**Step 4: Implement improved errors in loader.ts** + +Update `src/loader.ts` `loadSpec` function: + +```typescript +async function loadSpec(input: string): Promise> { + if (isUrl(input)) { + return loadSpecFromUrl(input) + } + + if (!existsSync(input)) { + throw new Error(`Cannot find spec file: ${input}. Check the path and try again.`) + } + + let raw: string + try { + raw = readFileSync(input, 'utf8') + } catch (err) { + throw new Error(`Cannot read spec file: ${input}. ${(err as Error).message}`) + } + + let parsed: Record + try { + parsed = input.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) + } catch (err) { + throw new Error(`Failed to parse ${input}: ${(err as Error).message}. Ensure the file is valid YAML or JSON.`) + } + + const version = detectSpecVersion(parsed) + if (version === 'unknown') { + throw new Error(`Unrecognized spec format in ${input}. Expected OpenAPI 3.x or Swagger 2.0.`) + } + + // ... rest unchanged +} +``` + +Add `import { existsSync } from 'fs'` to the imports. + +**Step 5: Implement warning in ir.ts** + +Add at the top of `extractIR`: + +```typescript +function extractIR(spec: Record): IR { + const paths = (spec.paths ?? {}) as Record> + + if (Object.keys(paths).length === 0) { + console.warn("Warning: Spec has no 'paths' — 0 operations will be extracted.") + } + + // ... rest unchanged +} +``` + +**Step 6: Run tests to verify they pass** + +Run: `bun test tests/loader.test.ts tests/ir.test.ts` +Expected: PASS + +**Step 7: Run full test suite** + +Run: `bun test` +Expected: All tests pass + +**Step 8: Commit** + +``` +fix(loader,ir): improve error messages for file not found, parse failures, and empty specs +``` + +--- + +### Task 10: E2E Integration Test with allOf Fixture + +**Files:** +- Test: `tests/e2e.test.ts` + +**Step 1: Write e2e test for allOf composition** + +Add to `tests/e2e.test.ts`: + +```typescript +describe('e2e: allOf composition', () => { + it('generates correct types from allOf spec', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/allof-composition.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-e2e-')) + + try { + writeGeneratedFiles(ir, outDir) + + const types = readFileSync(join(outDir, 'types.ts'), 'utf8') + // User should have merged properties from BaseEntity + inline + expect(types).toContain('export interface User') + expect(types).toContain('id: string') + expect(types).toContain('createdAt: string') + expect(types).toContain('name: string') + expect(types).toContain('email?: string') + + const hooks = readFileSync(join(outDir, 'hooks.ts'), 'utf8') + expect(hooks).toContain('useListUsers') + expect(hooks).toContain('useCreateUser') + } finally { + rmSync(outDir, { recursive: true }) + } + }) +}) +``` + +**Step 2: Run test** + +Run: `bun test tests/e2e.test.ts -- -t "allOf composition"` +Expected: PASS (if Task 2 was completed correctly) + +**Step 3: Commit** + +``` +test(e2e): add integration test for allOf composition +``` + +--- + +### Task 11: Final Verification & Version Bump + +**Step 1: Run full test suite** + +Run: `bun test` +Expected: All tests pass + +**Step 2: Run typecheck** + +Run: `bun run typecheck` +Expected: No errors + +**Step 3: Run build** + +Run: `bun run build` +Expected: Builds successfully + +**Step 4: Bump version to 0.3.0** + +Update `package.json` version field from `"0.2.3"` to `"0.3.0"`. + +**Step 5: Commit** + +``` +chore: bump version to 0.3.0 +``` diff --git a/package.json b/package.json index 2f752be..42e26f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apigen-tanstack", - "version": "0.2.3", + "version": "0.3.0", "description": "Generate TanStack Query v5 React hooks from OpenAPI/Swagger specs with built-in test mode", "keywords": [ "openapi", diff --git a/src/cli.ts b/src/cli.ts index 52e7c9f..36d67e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,13 +2,16 @@ import { Command } from 'commander' import { resolve, dirname, join } from 'path' -import { readFileSync } from 'fs' -import { fileURLToPath } from 'url' -import { select, input } from '@inquirer/prompts' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { fileURLToPath, pathToFileURL } from 'url' +import { select, input, confirm } from '@inquirer/prompts' import { loadSpec } from './loader' import { extractIR } from './ir' import { writeGeneratedFiles } from './writer' +import type { FileInfo } from './writer' import { discoverSpec } from './discover' +import { resolveConfig } from './config' +import type { ConfigInput } from './config' async function promptForInput(): Promise { const source = await select({ @@ -50,6 +53,73 @@ async function promptForInput(): Promise { return result.url } +async function loadConfigFile(configPath: string): Promise { + const resolved = resolve(configPath) + if (!existsSync(resolved)) { + throw new Error(`Config file not found: ${configPath}`) + } + const module = await import(pathToFileURL(resolved).toString()) + return module.default ?? module +} + +function findConfigFile(): string | null { + for (const name of ['apigen.config.ts', 'apigen.config.js']) { + if (existsSync(resolve(name))) return resolve(name) + } + return null +} + +function writeConfigFile(config: ConfigInput): void { + const lines = [ + `import { defineConfig } from 'apigen-tanstack'`, + ``, + `export default defineConfig(${JSON.stringify(config, null, 2)})`, + ``, + ] + writeFileSync('apigen.config.ts', lines.join('\n'), 'utf8') +} + +async function promptForConfig(inputValue: string): Promise { + const output = await input({ + message: 'Output directory:', + default: './src/api/generated', + }) + + const mock = await confirm({ + message: 'Generate mock data?', + default: true, + }) + + const split = await confirm({ + message: 'Split output by API tags?', + default: false, + }) + + const baseURL = await input({ + message: 'Base URL for API calls (leave empty for relative paths):', + }) + + const configInput: ConfigInput = { + input: inputValue, + output: output.trim(), + mock, + split, + ...(baseURL.trim() ? { baseURL: baseURL.trim() } : {}), + } + + const shouldSave = await confirm({ + message: 'Save as apigen.config.ts?', + default: true, + }) + + if (shouldSave) { + writeConfigFile(configInput) + console.log('Saved apigen.config.ts') + } + + return configInput +} + const __dirname = dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')) @@ -67,11 +137,45 @@ program .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()) + .option('-c, --config ', 'Path to config file (searches for apigen.config.ts by default)') + .option('--base-url ', 'Base URL prefix for all API fetch paths') + .option('--dry-run', 'Preview files that would be generated without writing') + .action(async (options: { input?: string; output: string; mock: boolean; split?: boolean; config?: string; baseUrl?: string; dryRun?: boolean }) => { + // Load config file (explicit or auto-search) + let fileConfig: ConfigInput | null = null + if (options.config) { + fileConfig = await loadConfigFile(options.config) + } else { + const found = findConfigFile() + if (found) { + console.log(`Using config file: ${found}`) + fileConfig = await loadConfigFile(found) + } + } + + let config: ReturnType + let inputValue: string + + // If no config file and no -i flag, run interactive wizard + if (!fileConfig && !options.input) { + inputValue = await promptForInput() + const wizardConfig = await promptForConfig(inputValue) + config = resolveConfig(wizardConfig) + } else { + // Merge: CLI flags override config file + config = resolveConfig({ + input: options.input ?? fileConfig?.input ?? '', + output: options.output !== './src/api/generated' ? options.output : (fileConfig?.output ?? options.output), + mock: options.mock !== undefined ? options.mock : fileConfig?.mock, + split: options.split ?? fileConfig?.split, + baseURL: options.baseUrl ?? fileConfig?.baseURL, + apiFetchImportPath: fileConfig?.apiFetchImportPath, + }) + inputValue = config.input || (await promptForInput()) + } const isUrlInput = inputValue.startsWith('http://') || inputValue.startsWith('https://') const inputPath = isUrlInput ? inputValue : resolve(inputValue) - const outputPath = resolve(options.output) + const outputPath = resolve(config.output) console.log(`Reading spec from ${inputPath}`) @@ -80,7 +184,51 @@ program console.log(`Found ${ir.operations.length} operations, ${ir.schemas.length} schemas`) - writeGeneratedFiles(ir, outputPath, { mock: options.mock, split: options.split }) + if (options.dryRun) { + const files = writeGeneratedFiles(ir, outputPath, { + mock: config.mock, + split: config.split, + baseURL: config.baseURL, + apiFetchImportPath: config.apiFetchImportPath, + dryRun: true, + }) as FileInfo[] + const totalSize = files.reduce((sum, f) => sum + f.size, 0) + + console.log('\nDry run — files that would be generated:\n') + for (const f of files) { + const sizeStr = f.size > 1024 ? `${(f.size / 1024).toFixed(1)} KB` : `${f.size} B` + console.log(` ${f.path} (${sizeStr})`) + } + const totalStr = totalSize > 1024 ? `${(totalSize / 1024).toFixed(1)} KB` : `${totalSize} B` + console.log(`\n Total: ${files.length} files, ${totalStr}\n`) + + // In non-TTY (CI), just print and exit + if (!process.stdin.isTTY) return + + // In TTY, ask to proceed + const proceed = await confirm({ message: 'Proceed with generation?' }) + if (!proceed) { + console.log('Cancelled.') + return + } + + // User said yes — do the actual write + writeGeneratedFiles(ir, outputPath, { + mock: config.mock, + split: config.split, + baseURL: config.baseURL, + apiFetchImportPath: config.apiFetchImportPath, + }) + console.log(`Generated files written to ${outputPath}`) + return + } + + writeGeneratedFiles(ir, outputPath, { + mock: config.mock, + split: config.split, + baseURL: config.baseURL, + apiFetchImportPath: config.apiFetchImportPath, + }) console.log(`Generated files written to ${outputPath}`) }) diff --git a/src/config.ts b/src/config.ts index 00c2055..1046359 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,12 +2,18 @@ interface Config { input: string output: string mock: boolean + split: boolean + baseURL?: string + apiFetchImportPath?: string } interface ConfigInput { input: string output?: string mock?: boolean + split?: boolean + baseURL?: string + apiFetchImportPath?: string } function defineConfig(config: ConfigInput): Config { @@ -19,6 +25,9 @@ function resolveConfig(input: ConfigInput): Config { input: input.input, output: input.output ?? './src/api/generated', mock: input.mock ?? true, + split: input.split ?? false, + baseURL: input.baseURL, + apiFetchImportPath: input.apiFetchImportPath, } } diff --git a/src/generators/api-fetch.ts b/src/generators/api-fetch.ts index f70057f..c7572c5 100644 --- a/src/generators/api-fetch.ts +++ b/src/generators/api-fetch.ts @@ -1,10 +1,15 @@ -function generateApiFetch(): string { +function generateApiFetch(options?: { baseURL?: string }): string { + const baseURL = options?.baseURL const lines: string[] = [] lines.push('/* eslint-disable */') lines.push('/* This file is auto-generated by apigen. Do not edit. */') lines.push('') lines.push('export function apiFetch(path: string, init?: RequestInit): Promise {') - lines.push(' return fetch(path, {') + if (baseURL) { + lines.push(` return fetch(\`${baseURL}\${path}\`, {`) + } else { + lines.push(' return fetch(path, {') + } lines.push(" headers: { 'Content-Type': 'application/json' },") lines.push(' ...init,') lines.push(' }).then(res => {') diff --git a/src/generators/hooks.ts b/src/generators/hooks.ts index e612c19..2889ea8 100644 --- a/src/generators/hooks.ts +++ b/src/generators/hooks.ts @@ -181,7 +181,7 @@ function collectMockImports(ir: IR): string[] { return [...mocks] } -function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: string; apiFetchImportPath?: string }): string { +function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: string; apiFetchImportPath?: string; baseURL?: string }): string { const mock = options?.mock ?? true const providerImportPath = options?.providerImportPath ?? './test-mode-provider' const apiFetchImportPath = options?.apiFetchImportPath @@ -213,8 +213,13 @@ function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: parts.push('') if (!apiFetchImportPath) { + const baseURL = options?.baseURL parts.push(`function apiFetch(path: string, init?: RequestInit): Promise {`) - parts.push(` return fetch(path, {`) + if (baseURL) { + parts.push(` return fetch(\`${baseURL}\${path}\`, {`) + } else { + parts.push(` return fetch(path, {`) + } parts.push(` headers: { 'Content-Type': 'application/json' },`) parts.push(` ...init,`) parts.push(` }).then(res => {`) diff --git a/src/generators/mocks.ts b/src/generators/mocks.ts index d294954..d496fdc 100644 --- a/src/generators/mocks.ts +++ b/src/generators/mocks.ts @@ -79,6 +79,8 @@ function mockPropertyValue(prop: IRProperty, schemas: IRSchema[]): string { if (prop.enumValues && prop.enumValues.length > 0) { return `'${prop.enumValues[0]}'` } + if (prop.type === 'object') return '{}' + if (prop.type === 'unknown') return 'null as unknown' return fakerValueForField(prop.name, prop.type) } diff --git a/src/ir.ts b/src/ir.ts index ff4246e..2f474ae 100644 --- a/src/ir.ts +++ b/src/ir.ts @@ -203,8 +203,47 @@ function extractInlineSchema(name: string, schema: Record): IRS return { name, properties, required } } +function resolveAllOf( + allOfArray: Record[], + schemasDef: Record>, +): { properties: Record>; required: string[] } { + const mergedProps: Record> = {} + const mergedRequired: string[] = [] + + for (const variant of allOfArray) { + let variantSchema = variant + + // Resolve $ref to component schema + if (variant.$ref && typeof variant.$ref === 'string') { + const refName = (variant.$ref as string).split('/').pop() + if (refName && schemasDef[refName]) { + variantSchema = schemasDef[refName] + // If the referenced schema itself uses allOf, resolve it recursively + if (Array.isArray(variantSchema.allOf)) { + const resolved = resolveAllOf(variantSchema.allOf as Record[], schemasDef) + Object.assign(mergedProps, resolved.properties) + mergedRequired.push(...resolved.required) + continue + } + } + } + + const props = (variantSchema.properties ?? {}) as Record> + const req = (variantSchema.required ?? []) as string[] + Object.assign(mergedProps, props) + mergedRequired.push(...req) + } + + return { properties: mergedProps, required: mergedRequired } +} + function extractIR(spec: Record): IR { const paths = (spec.paths ?? {}) as Record> + + if (Object.keys(paths).length === 0) { + console.warn("Warning: Spec has no 'paths' -- 0 operations will be extracted.") + } + const components = (spec.components ?? {}) as Record const schemasDef = (components.schemas ?? {}) as Record> @@ -291,8 +330,17 @@ function extractIR(spec: Record): IR { } for (const [name, schemaDef] of Object.entries(schemasDef)) { - const props = (schemaDef.properties ?? {}) as Record> - const required = (schemaDef.required ?? []) as string[] + let props: Record> + let required: string[] + + if (Array.isArray(schemaDef.allOf)) { + const resolved = resolveAllOf(schemaDef.allOf as Record[], schemasDef) + props = resolved.properties + required = resolved.required + } else { + props = (schemaDef.properties ?? {}) as Record> + required = (schemaDef.required ?? []) as string[] + } const properties: IRProperty[] = Object.entries(props).map(([propName, propSchema]) => { const isArray = propSchema.type === 'array' || mapOpenApiType(propSchema) === 'array' diff --git a/src/loader.ts b/src/loader.ts index 27ced1e..b30a903 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'fs' +import { existsSync, readFileSync } from 'fs' import { parse as parseYaml } from 'yaml' import { bundle, createConfig } from '@redocly/openapi-core' import converter from 'swagger2openapi' @@ -53,12 +53,23 @@ async function loadSpec(input: string): Promise> { return loadSpecFromUrl(input) } + if (!existsSync(input)) { + throw new Error(`Cannot find spec file: ${input}. Check the path and try again.`) + } + const raw = readFileSync(input, 'utf8') - const parsed = input.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) + + let parsed: Record + try { + parsed = input.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) + } catch (err) { + throw new Error(`Failed to parse ${input}: ${(err as Error).message}. Ensure the file is valid YAML or JSON.`) + } + const version = detectSpecVersion(parsed) if (version === 'unknown') { - throw new Error(`Unrecognized spec format in ${input}`) + throw new Error(`Unrecognized spec format in ${input}. Expected OpenAPI 3.x or Swagger 2.0.`) } if (version === 'swagger2') { diff --git a/src/writer.ts b/src/writer.ts index b013345..65b1c76 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -48,11 +48,11 @@ function buildSubsetIR(ops: IROperation[], allSchemas: IRSchema[]): IR { return { operations: ops, schemas } } -function writeFlat(ir: IR, outputDir: string, mock: boolean): void { +function writeFlat(ir: IR, outputDir: string, mock: boolean, opts?: { baseURL?: string; apiFetchImportPath?: string }): void { mkdirSync(outputDir, { recursive: true }) writeFileSync(join(outputDir, 'types.ts'), generateTypes(ir), 'utf8') - writeFileSync(join(outputDir, 'hooks.ts'), generateHooks(ir, { mock }), 'utf8') + writeFileSync(join(outputDir, 'hooks.ts'), generateHooks(ir, { mock, baseURL: opts?.baseURL, apiFetchImportPath: opts?.apiFetchImportPath }), 'utf8') if (mock) { writeFileSync(join(outputDir, 'mocks.ts'), generateMocks(ir), 'utf8') writeFileSync(join(outputDir, 'test-mode-provider.tsx'), generateProvider(), 'utf8') @@ -60,7 +60,7 @@ function writeFlat(ir: IR, outputDir: string, mock: boolean): void { writeFileSync(join(outputDir, 'index.ts'), generateIndexFile({ mock }), 'utf8') } -function writeSplit(ir: IR, outputDir: string, mock: boolean): void { +function writeSplit(ir: IR, outputDir: string, mock: boolean, opts?: { baseURL?: string }): void { mkdirSync(outputDir, { recursive: true }) const groups = groupOperationsByTag(ir.operations) @@ -72,7 +72,7 @@ function writeSplit(ir: IR, outputDir: string, mock: boolean): void { } // Write shared api-fetch at root - writeFileSync(join(outputDir, 'api-fetch.ts'), generateApiFetch(), 'utf8') + writeFileSync(join(outputDir, 'api-fetch.ts'), generateApiFetch({ baseURL: opts?.baseURL }), 'utf8') // Write per-tag feature folders for (const slug of tagSlugs) { @@ -101,15 +101,73 @@ function writeSplit(ir: IR, outputDir: string, mock: boolean): void { ) } -function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean }): void { +interface FileInfo { + path: string + size: number +} + +function collectFileInfo( + ir: IR, + outputDir: string, + opts: { mock: boolean; split: boolean; baseURL?: string; apiFetchImportPath?: string }, +): FileInfo[] { + const files: FileInfo[] = [] + + if (opts.split) { + const groups = groupOperationsByTag(ir.operations) + const tagSlugs = [...groups.keys()].sort() + + if (opts.mock) { + const content = generateProvider() + files.push({ path: join(outputDir, 'test-mode-provider.tsx'), size: Buffer.byteLength(content) }) + } + const apiFetchContent = generateApiFetch({ baseURL: opts.baseURL }) + files.push({ path: join(outputDir, 'api-fetch.ts'), size: Buffer.byteLength(apiFetchContent) }) + + for (const slug of tagSlugs) { + const ops = groups.get(slug)! + const subsetIR = buildSubsetIR(ops, ir.schemas) + const featureDir = join(outputDir, slug) + + files.push({ path: join(featureDir, 'types.ts'), size: Buffer.byteLength(generateTypes(subsetIR)) }) + files.push({ path: join(featureDir, 'hooks.ts'), size: Buffer.byteLength(generateHooks(subsetIR, { mock: opts.mock, providerImportPath: '../test-mode-provider', apiFetchImportPath: '../api-fetch' })) }) + if (opts.mock) { + files.push({ path: join(featureDir, 'mocks.ts'), size: Buffer.byteLength(generateMocks(subsetIR)) }) + } + files.push({ path: join(featureDir, 'index.ts'), size: Buffer.byteLength(generateIndexFile({ mock: opts.mock, includeProvider: false })) }) + } + + files.push({ path: join(outputDir, 'index.ts'), size: Buffer.byteLength(generateRootIndexFile(tagSlugs, { mock: opts.mock })) }) + } else { + files.push({ path: join(outputDir, 'types.ts'), size: Buffer.byteLength(generateTypes(ir)) }) + files.push({ path: join(outputDir, 'hooks.ts'), size: Buffer.byteLength(generateHooks(ir, { mock: opts.mock, baseURL: opts.baseURL, apiFetchImportPath: opts.apiFetchImportPath })) }) + if (opts.mock) { + files.push({ path: join(outputDir, 'mocks.ts'), size: Buffer.byteLength(generateMocks(ir)) }) + files.push({ path: join(outputDir, 'test-mode-provider.tsx'), size: Buffer.byteLength(generateProvider()) }) + } + files.push({ path: join(outputDir, 'index.ts'), size: Buffer.byteLength(generateIndexFile({ mock: opts.mock })) }) + } + + return files +} + +function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean; baseURL?: string; apiFetchImportPath?: string; dryRun?: boolean }): FileInfo[] | void { const mock = options?.mock ?? true const split = options?.split ?? false + const dryRun = options?.dryRun ?? false + const baseURL = options?.baseURL + const apiFetchImportPath = options?.apiFetchImportPath + + if (dryRun) { + return collectFileInfo(ir, outputDir, { mock, split, baseURL, apiFetchImportPath }) + } if (split) { - writeSplit(ir, outputDir, mock) + writeSplit(ir, outputDir, mock, { baseURL }) } else { - writeFlat(ir, outputDir, mock) + writeFlat(ir, outputDir, mock, { baseURL, apiFetchImportPath }) } } export { writeGeneratedFiles } +export type { FileInfo } diff --git a/tests/config.test.ts b/tests/config.test.ts index 21f0705..37f7f8f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -17,4 +17,27 @@ describe('config', () => { expect(config.output).toBe('./src/api/generated') expect(config.mock).toBe(true) }) + + it('resolveConfig applies split default to false', () => { + const config = resolveConfig({ input: './spec.yaml' }) + expect(config.split).toBe(false) + }) + + it('resolveConfig passes through split, baseURL, apiFetchImportPath', () => { + const config = resolveConfig({ + input: './spec.yaml', + split: true, + baseURL: 'https://api.example.com', + apiFetchImportPath: './lib/api-client', + }) + expect(config.split).toBe(true) + expect(config.baseURL).toBe('https://api.example.com') + expect(config.apiFetchImportPath).toBe('./lib/api-client') + }) + + it('resolveConfig leaves baseURL and apiFetchImportPath undefined when not set', () => { + const config = resolveConfig({ input: './spec.yaml' }) + expect(config.baseURL).toBeUndefined() + expect(config.apiFetchImportPath).toBeUndefined() + }) }) diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index f08df32..71f2933 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -196,6 +196,32 @@ describe('e2e: --split flag', () => { }) }) +describe('e2e: allOf composition', () => { + it('generates correct types from allOf spec', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/allof-composition.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-e2e-')) + + try { + writeGeneratedFiles(ir, outDir) + + const types = readFileSync(join(outDir, 'types.ts'), 'utf8') + // User should have merged properties from BaseEntity + inline + expect(types).toContain('export interface User') + expect(types).toContain('id: string') + expect(types).toContain('createdAt?: string') + expect(types).toContain('name: string') + expect(types).toContain('email?: string') + + const hooks = readFileSync(join(outDir, 'hooks.ts'), 'utf8') + expect(hooks).toContain('useListUsers') + expect(hooks).toContain('useCreateUser') + } 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/allof-composition.yaml b/tests/fixtures/allof-composition.yaml new file mode 100644 index 0000000..c2ed6c4 --- /dev/null +++ b/tests/fixtures/allof-composition.yaml @@ -0,0 +1,63 @@ +openapi: "3.0.3" +info: + title: allOf Test + version: "1.0" +paths: + /users: + get: + operationId: listUsers + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + post: + operationId: createUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserBody" + responses: + "201": + description: created + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + BaseEntity: + type: object + required: + - id + properties: + id: + type: string + createdAt: + type: string + format: date-time + User: + allOf: + - $ref: "#/components/schemas/BaseEntity" + - type: object + required: + - name + properties: + name: + type: string + email: + type: string + CreateUserBody: + type: object + required: + - name + properties: + name: + type: string + email: + type: string diff --git a/tests/generators/api-fetch.test.ts b/tests/generators/api-fetch.test.ts index 939ca77..5d5140e 100644 --- a/tests/generators/api-fetch.test.ts +++ b/tests/generators/api-fetch.test.ts @@ -24,4 +24,16 @@ describe('generateApiFetch', () => { expect(output).toContain('/* eslint-disable */') expect(output).toContain('auto-generated') }) + + it('generates apiFetch with baseURL when provided', () => { + const output = generateApiFetch({ baseURL: 'https://api.example.com' }) + expect(output).toContain('https://api.example.com') + expect(output).toContain('`https://api.example.com${path}`') + }) + + it('generates apiFetch without baseURL when not provided', () => { + const output = generateApiFetch() + expect(output).not.toContain('https://') + expect(output).toContain('fetch(path') + }) }) diff --git a/tests/generators/hooks.test.ts b/tests/generators/hooks.test.ts index 9d800ce..f36bf85 100644 --- a/tests/generators/hooks.test.ts +++ b/tests/generators/hooks.test.ts @@ -68,4 +68,22 @@ describe('generateHooks', () => { expect(output).not.toContain('testMode') expect(output).toContain('apiFetch') }) + + it('generates inline apiFetch with baseURL when provided', async () => { + const spec = await loadSpec(resolve(__dirname, '../fixtures/petstore-oas3.yaml')) + const ir = extractIR(spec) + const output = generateHooks(ir, { mock: false, baseURL: 'https://api.example.com' }) + + expect(output).toContain('https://api.example.com') + expect(output).toContain('`https://api.example.com${path}`') + }) + + it('generates inline apiFetch without baseURL when not provided', async () => { + const spec = await loadSpec(resolve(__dirname, '../fixtures/petstore-oas3.yaml')) + const ir = extractIR(spec) + const output = generateHooks(ir, { mock: false }) + + expect(output).toContain('fetch(path') + expect(output).not.toContain('https://api.example.com') + }) }) diff --git a/tests/generators/mocks.test.ts b/tests/generators/mocks.test.ts index da3eeb7..2381980 100644 --- a/tests/generators/mocks.test.ts +++ b/tests/generators/mocks.test.ts @@ -115,4 +115,24 @@ describe('generateMocks', () => { expect(output).toContain('data: {},') expect(output).toContain('meta: null as unknown,') }) + + it('generates {} for object-typed properties even when name matches faker heuristic', () => { + const ir: IR = { + operations: [], + schemas: [{ + name: 'Insurance', + properties: [ + { name: 'address', type: 'object', required: false, isArray: false, itemType: null, ref: null, enumValues: null }, + { name: 'contact', type: 'object', required: false, isArray: false, itemType: null, ref: null, enumValues: null }, + ], + required: [], + }], + } + const output = generateMocks(ir) + + expect(output).toContain('address: {},') + expect(output).toContain('contact: {},') + expect(output).not.toMatch(/address: '/) + expect(output).not.toMatch(/contact: '/) + }) }) diff --git a/tests/ir.test.ts b/tests/ir.test.ts index 9bb70ff..6200978 100644 --- a/tests/ir.test.ts +++ b/tests/ir.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { resolve } from 'path' import { loadSpec } from '../src/loader' import { extractIR } from '../src/ir' @@ -270,6 +270,84 @@ describe('extractIR', () => { expect(ids).toContain('searchUsers') }) + it('resolves allOf by merging properties from all variants', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/allof-composition.yaml')) + const ir = extractIR(spec) + + const user = ir.schemas.find(s => s.name === 'User') + expect(user).toBeDefined() + // Should have merged properties from BaseEntity + inline + expect(user!.properties).toHaveLength(4) + expect(user!.properties.find(p => p.name === 'id')).toBeDefined() + expect(user!.properties.find(p => p.name === 'createdAt')).toBeDefined() + expect(user!.properties.find(p => p.name === 'name')).toBeDefined() + expect(user!.properties.find(p => p.name === 'email')).toBeDefined() + // Required should merge from both + expect(user!.required).toContain('id') + expect(user!.required).toContain('name') + }) + + it('resolves allOf with inline-only variants (no $ref)', () => { + const spec = { + paths: {}, + components: { + schemas: { + Merged: { + allOf: [ + { type: 'object', required: ['a'], properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }, + }, + }, + } + const ir = extractIR(spec as Record) + const merged = ir.schemas.find(s => s.name === 'Merged') + expect(merged).toBeDefined() + expect(merged!.properties).toHaveLength(2) + expect(merged!.properties.find(p => p.name === 'a')!.type).toBe('string') + expect(merged!.properties.find(p => p.name === 'b')!.type).toBe('number') + expect(merged!.required).toContain('a') + }) + + it('detects circular references and breaks the cycle', () => { + const spec = { + paths: {}, + components: { + schemas: { + User: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + manager: { $ref: '#/components/schemas/User' }, + }, + }, + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/components/schemas/Node' } }, + }, + }, + }, + }, + } + const ir = extractIR(spec as Record) + + // Should complete without infinite loop + expect(ir.schemas).toHaveLength(2) + + const user = ir.schemas.find(s => s.name === 'User') + expect(user).toBeDefined() + expect(user!.properties.find(p => p.name === 'manager')!.ref).toBe('#/components/schemas/User') + + const node = ir.schemas.find(s => s.name === 'Node') + expect(node).toBeDefined() + const children = node!.properties.find(p => p.name === 'children')! + expect(children.isArray).toBe(true) + }) + it('resolves anyOf nullable types to base type', () => { const spec = { paths: { @@ -304,4 +382,13 @@ describe('extractIR', () => { expect(bodySchema!.properties.find(p => p.name === 'code')!.type).toBe('string') expect(bodySchema!.properties.find(p => p.name === 'count')!.type).toBe('number') }) + + it('warns when spec has no paths', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const spec = { components: { schemas: {} } } + const ir = extractIR(spec as Record) + expect(ir.operations).toHaveLength(0) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("no 'paths'")) + warnSpy.mockRestore() + }) }) diff --git a/tests/loader.test.ts b/tests/loader.test.ts index eaeb55a..fddbd5e 100644 --- a/tests/loader.test.ts +++ b/tests/loader.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterAll } from 'vitest' -import { resolve } from 'path' -import { readFileSync } from 'fs' +import { resolve, join } from 'path' +import { readFileSync, writeFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' import { createServer, type Server } from 'http' import { loadSpec, detectSpecVersion, isUrl } from '../src/loader' @@ -49,6 +50,22 @@ describe('loadSpec', () => { }) }) +describe('loadSpec error messages', () => { + it('throws user-friendly error for file not found', async () => { + await expect(loadSpec('./nonexistent-spec.yaml')).rejects.toThrow('Cannot find spec file') + }) + + it('throws user-friendly error for unparseable file', async () => { + const tmpFile = join(tmpdir(), 'bad-spec-' + Date.now() + '.yaml') + writeFileSync(tmpFile, '{{invalid yaml content', 'utf8') + try { + await expect(loadSpec(tmpFile)).rejects.toThrow('Failed to parse') + } finally { + rmSync(tmpFile) + } + }) +}) + describe('loadSpec from URL', () => { let server: Server let baseUrl: string diff --git a/tests/writer.test.ts b/tests/writer.test.ts index f26afce..b36bbdb 100644 --- a/tests/writer.test.ts +++ b/tests/writer.test.ts @@ -59,4 +59,57 @@ describe('writeGeneratedFiles', () => { rmSync(outDir, { recursive: true }) } }) + + it('passes baseURL to generated hooks when provided', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/petstore-oas3.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-test-')) + + try { + writeGeneratedFiles(ir, outDir, { mock: true, baseURL: 'https://api.example.com' }) + + const hooks = readFileSync(join(outDir, 'hooks.ts'), 'utf8') + expect(hooks).toContain('https://api.example.com') + } finally { + rmSync(outDir, { recursive: true }) + } + }) + + it('passes baseURL to split mode api-fetch when provided', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/tagged-api.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-test-')) + + try { + writeGeneratedFiles(ir, outDir, { split: true, baseURL: 'https://api.example.com' }) + + const apiFetch = readFileSync(join(outDir, 'api-fetch.ts'), 'utf8') + expect(apiFetch).toContain('https://api.example.com') + } finally { + rmSync(outDir, { recursive: true }) + } + }) + + it('returns file metadata without writing when dryRun is true', async () => { + const spec = await loadSpec(resolve(__dirname, 'fixtures/petstore-oas3.yaml')) + const ir = extractIR(spec) + const outDir = mkdtempSync(join(tmpdir(), 'oqf-test-')) + + try { + const result = writeGeneratedFiles(ir, outDir, { mock: true, dryRun: true }) + + // Should return file info + expect(result).toBeDefined() + expect(result!.length).toBeGreaterThan(0) + expect(result!.some(f => f.path.endsWith('types.ts'))).toBe(true) + expect(result!.some(f => f.path.endsWith('hooks.ts'))).toBe(true) + expect(result!.every(f => f.size > 0)).toBe(true) + + // Should NOT have written any files + expect(existsSync(join(outDir, 'types.ts'))).toBe(false) + expect(existsSync(join(outDir, 'hooks.ts'))).toBe(false) + } finally { + rmSync(outDir, { recursive: true }) + } + }) })