From 4da54e4ad6da220e8a2dad381c2659bb1a4606b6 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:10:36 +0700 Subject: [PATCH 01/18] docs: tier 1 CLI improvements design and implementation plan Includes interactive config init wizard and dry-run confirmation prompt using @inquirer/prompts alongside the original 6 features: config file loading, extended config, --dry-run, error messages, circular ref detection, and allOf composition. --- ...026-02-26-tier1-cli-improvements-design.md | 260 ++++ .../2026-02-26-tier1-cli-improvements.md | 1244 +++++++++++++++++ 2 files changed, 1504 insertions(+) create mode 100644 docs/plans/2026-02-26-tier1-cli-improvements-design.md create mode 100644 docs/plans/2026-02-26-tier1-cli-improvements.md 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 +``` From bebbac023ef308abcdf097c159044cb93ba0e05f Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:17:21 +0700 Subject: [PATCH 02/18] feat(ir): add circular reference detection test Verify that self-referencing (User.manager -> User) and recursive (Node.children -> Node[]) schemas are handled without infinite loops. The current IR extraction already handles this by keeping $ref as string properties without recursion. --- tests/ir.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/ir.test.ts b/tests/ir.test.ts index 9bb70ff..3c61d9a 100644 --- a/tests/ir.test.ts +++ b/tests/ir.test.ts @@ -270,6 +270,44 @@ describe('extractIR', () => { expect(ids).toContain('searchUsers') }) + 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: { From 1870ca9b97b42156035969d55df1574f872762a1 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:17:51 +0700 Subject: [PATCH 03/18] feat(config): add split, baseURL, apiFetchImportPath to ConfigInput --- src/config.ts | 9 +++++++++ tests/config.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) 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/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() + }) }) From 0b4b4f919919adfcc8734c7b2db0bbcdfbf851c1 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:17:58 +0700 Subject: [PATCH 04/18] fix(loader): improve error messages for file not found and parse failures - Add existsSync check before reading, throws "Cannot find spec file: {path}" - Wrap YAML/JSON parse in try/catch, throws "Failed to parse {path}: {error}" - Improve unrecognized format message with expected formats hint - Add tests for both error cases --- src/loader.ts | 17 ++++++++++++++--- tests/loader.test.ts | 21 +++++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/loader.ts b/src/loader.ts index 27ced1e..d5b3f78 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}`) + } + 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/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 From 82b083e415d27917b22d5188e85f2bd4641ab04c Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:18:40 +0700 Subject: [PATCH 05/18] feat(ir): support allOf composition via property merging Add resolveAllOf() function that merges properties from all allOf variants. For $ref variants, it looks up the referenced schema and collects its properties. For inline variants, it collects properties directly. Required arrays are unioned from all variants. Wire into extractIR() schema loop to detect schemaDef.allOf and resolve before property extraction. --- src/ir.ts | 47 +++++++++++++++++++- tests/fixtures/allof-composition.yaml | 63 +++++++++++++++++++++++++++ tests/ir.test.ts | 40 +++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/allof-composition.yaml diff --git a/src/ir.ts b/src/ir.ts index ff4246e..8c7a978 100644 --- a/src/ir.ts +++ b/src/ir.ts @@ -203,6 +203,40 @@ 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> const components = (spec.components ?? {}) as Record @@ -291,8 +325,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/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/ir.test.ts b/tests/ir.test.ts index 3c61d9a..6eba7ad 100644 --- a/tests/ir.test.ts +++ b/tests/ir.test.ts @@ -270,6 +270,46 @@ 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: {}, From 12ed549bea1d08b6e8ef6628f813650660fca0d4 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:19:23 +0700 Subject: [PATCH 06/18] feat(generators): wire baseURL into apiFetch and hooks generation --- src/generators/api-fetch.ts | 9 +++++++-- src/generators/hooks.ts | 9 +++++++-- tests/generators/api-fetch.test.ts | 12 ++++++++++++ tests/generators/hooks.test.ts | 18 ++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) 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/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') + }) }) From f836682bfd4ebe2e83c57a939ed67bf491e65d74 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:20:25 +0700 Subject: [PATCH 07/18] fix(ir): warn when spec has no paths Add console.warn at the top of extractIR when the spec has no paths defined, so users get a clear message about 0 operations being extracted rather than silently generating empty output. --- src/ir.ts | 5 +++++ tests/ir.test.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ir.ts b/src/ir.ts index 8c7a978..2f474ae 100644 --- a/src/ir.ts +++ b/src/ir.ts @@ -239,6 +239,11 @@ function resolveAllOf( 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> diff --git a/tests/ir.test.ts b/tests/ir.test.ts index 6eba7ad..3dec5ac 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' @@ -382,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('0 operations')) + warnSpy.mockRestore() + }) }) From 3072aa85f078252dd838554c9bbf7437203301fb Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:22:01 +0700 Subject: [PATCH 08/18] feat(writer): pass baseURL and apiFetchImportPath through to generators - Add baseURL and apiFetchImportPath to writeGeneratedFiles options - Pass baseURL to generateHooks in flat mode - Pass baseURL to generateApiFetch in split mode - Pass apiFetchImportPath to generateHooks in flat mode - Add tests for baseURL passthrough in both flat and split modes --- src/writer.ts | 16 +++++++++------- tests/writer.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/writer.ts b/src/writer.ts index b013345..831d6e5 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,14 +101,16 @@ function writeSplit(ir: IR, outputDir: string, mock: boolean): void { ) } -function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean }): void { +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) + writeSplit(ir, outputDir, mock, { baseURL }) } else { - writeFlat(ir, outputDir, mock) + writeFlat(ir, outputDir, mock, { baseURL, apiFetchImportPath }) } } diff --git a/tests/writer.test.ts b/tests/writer.test.ts index f26afce..de8d6bb 100644 --- a/tests/writer.test.ts +++ b/tests/writer.test.ts @@ -59,4 +59,34 @@ 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 }) + } + }) }) From 10d401815243e3e3551f993d232ead6df659ea74 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:22:50 +0700 Subject: [PATCH 09/18] test(e2e): add integration test for allOf composition Verify the full pipeline (load -> IR -> generate -> write) correctly handles allOf specs. The test loads allof-composition.yaml, runs the pipeline, and verifies that User interface has merged properties from BaseEntity (id, createdAt) and inline (name, email), and that hooks for listUsers and createUser are generated. --- tests/e2e.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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')) From ff8e9489437167a28fb666736210bd32afa18def Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:22:51 +0700 Subject: [PATCH 10/18] fix(loader): add hint suffix to parse error message per design doc --- src/loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loader.ts b/src/loader.ts index d5b3f78..b30a903 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -63,7 +63,7 @@ async function loadSpec(input: string): Promise> { try { parsed = input.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) } catch (err) { - throw new Error(`Failed to parse ${input}: ${(err as Error).message}`) + throw new Error(`Failed to parse ${input}: ${(err as Error).message}. Ensure the file is valid YAML or JSON.`) } const version = detectSpecVersion(parsed) From 6cb0a9af6a42acdd7873ce03d51d80a31ccefe7f Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:24:00 +0700 Subject: [PATCH 11/18] feat(cli): add config file loading with --config flag and auto-search - Add loadConfigFile() for dynamic import of config files - Add findConfigFile() to auto-search for apigen.config.ts/js - Add -c/--config and --base-url CLI options - Merge config: CLI flags override file config, file config overrides defaults --- src/cli.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 52e7c9f..23ce779 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,13 +2,15 @@ import { Command } from 'commander' import { resolve, dirname, join } from 'path' -import { readFileSync } from 'fs' -import { fileURLToPath } from 'url' +import { existsSync, readFileSync } from 'fs' +import { fileURLToPath, pathToFileURL } from 'url' import { select, input } from '@inquirer/prompts' import { loadSpec } from './loader' import { extractIR } from './ir' import { writeGeneratedFiles } 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 +52,22 @@ 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 +} + const __dirname = dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')) @@ -67,11 +85,35 @@ 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') + .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()) 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 +122,12 @@ program console.log(`Found ${ir.operations.length} operations, ${ir.schemas.length} schemas`) - writeGeneratedFiles(ir, outputPath, { mock: options.mock, split: options.split }) + writeGeneratedFiles(ir, outputPath, { + mock: config.mock, + split: config.split, + baseURL: config.baseURL, + apiFetchImportPath: config.apiFetchImportPath, + }) console.log(`Generated files written to ${outputPath}`) }) From b135be13101ef248accf1840a9260939ec61fe8d Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:24:19 +0700 Subject: [PATCH 12/18] fix(test): use precise assertion for empty paths warning message Change stringContaining('0 operations') to stringContaining("no 'paths'") to match the actual warning message more precisely. --- tests/ir.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ir.test.ts b/tests/ir.test.ts index 3dec5ac..6200978 100644 --- a/tests/ir.test.ts +++ b/tests/ir.test.ts @@ -388,7 +388,7 @@ describe('extractIR', () => { const spec = { components: { schemas: {} } } const ir = extractIR(spec as Record) expect(ir.operations).toHaveLength(0) - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('0 operations')) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("no 'paths'")) warnSpy.mockRestore() }) }) From e36be85fa7241de112f703adbb673ac6a57e5f1a Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:28:12 +0700 Subject: [PATCH 13/18] feat(cli): add --dry-run flag with interactive confirmation prompt - Add FileInfo type and collectFileInfo() to writer for dry-run mode - writeGeneratedFiles returns FileInfo[] when dryRun is true, writes nothing - Add --dry-run CLI flag that previews files with sizes - In TTY mode, prompts to confirm before proceeding with generation - In non-TTY (CI), prints summary and exits --- src/cli.ts | 45 ++++++++++++++++++++++++++++++++-- src/writer.ts | 58 +++++++++++++++++++++++++++++++++++++++++++- tests/writer.test.ts | 23 ++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 23ce779..fb9444d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,10 +4,11 @@ import { Command } from 'commander' import { resolve, dirname, join } from 'path' import { existsSync, readFileSync } from 'fs' import { fileURLToPath, pathToFileURL } from 'url' -import { select, input } from '@inquirer/prompts' +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' @@ -87,7 +88,8 @@ program .option('--split', 'Split output into per-tag feature folders') .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 }) => { + .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) { @@ -122,6 +124,45 @@ program console.log(`Found ${ir.operations.length} operations, ${ir.schemas.length} schemas`) + 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, diff --git a/src/writer.ts b/src/writer.ts index 831d6e5..65b1c76 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -101,12 +101,67 @@ function writeSplit(ir: IR, outputDir: string, mock: boolean, opts?: { baseURL?: ) } -function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boolean; split?: boolean; baseURL?: string; apiFetchImportPath?: string }): 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, { baseURL }) } else { @@ -115,3 +170,4 @@ function writeGeneratedFiles(ir: IR, outputDir: string, options?: { mock?: boole } export { writeGeneratedFiles } +export type { FileInfo } diff --git a/tests/writer.test.ts b/tests/writer.test.ts index de8d6bb..b36bbdb 100644 --- a/tests/writer.test.ts +++ b/tests/writer.test.ts @@ -89,4 +89,27 @@ describe('writeGeneratedFiles', () => { 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 }) + } + }) }) From 22225e0fbfcd9197e17a9b2404f1a1c2317f758d Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:29:31 +0700 Subject: [PATCH 14/18] feat(cli): add interactive config init wizard with @inquirer/prompts - Add promptForConfig() wizard: prompts for output dir, mock, split, baseURL - Add writeConfigFile() to save apigen.config.ts with defineConfig - When no config file and no -i flag, wizard runs after spec input prompt - Wizard offers to save config for future runs --- src/cli.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index fb9444d..36d67e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { resolve, dirname, join } from 'path' -import { existsSync, readFileSync } from 'fs' +import { existsSync, readFileSync, writeFileSync } from 'fs' import { fileURLToPath, pathToFileURL } from 'url' import { select, input, confirm } from '@inquirer/prompts' import { loadSpec } from './loader' @@ -69,6 +69,57 @@ function findConfigFile(): string | null { 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')) @@ -102,17 +153,26 @@ program } } - // 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, - }) + let config: ReturnType + let inputValue: string - const inputValue = config.input || (await promptForInput()) + // 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(config.output) From dc0267bb5ce9854c51a2f5aa5ab086b4e803c7d6 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:30:20 +0700 Subject: [PATCH 15/18] chore: bump version to 0.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 1fa8f2a046322e4e349099fb95f2530b59ebf676 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 17:44:18 +0700 Subject: [PATCH 16/18] docs: sync documentation with v0.3.0 tier 1 CLI improvements Update all docs to reflect new features: config file loading, --config/--base-url/--dry-run flags, extended ConfigInput with split/baseURL/apiFetchImportPath, interactive wizard, allOf composition, and circular ref detection. Fix test count (87/13), add missing test files and fixtures to contributing guide. --- CLAUDE.md | 2 +- README.md | 14 ++++++ docs/api-reference.md | 42 ++++++++++++------ docs/architecture.md | 47 +++++++++++++++----- docs/configuration.md | 96 +++++++++++++++++++++++++++++++++++++++-- docs/contributing.md | 5 +++ docs/getting-started.md | 31 +++++++++++-- 7 files changed, 205 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0f9a9aa..d601a9b 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 (87 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 From eb4ada6bf4d97f18ee274848c5d2e5680cf48570 Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 18:04:05 +0700 Subject: [PATCH 17/18] fix(mocks): generate {} for object-typed props with faker-matching names Properties typed as `object` but named `address`, `contact`, etc. were incorrectly getting faker string values instead of `{}` because the name-based heuristic ran before the type check. Move object/unknown type guards above the fakerValueForField fallback in mockPropertyValue. Found via real-world testing against masterdata-tool (58 operations). --- src/generators/mocks.ts | 2 ++ tests/generators/mocks.test.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) 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/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: '/) + }) }) From 7fb86bb3dd66e7e47b1cc324fae059150164b45f Mon Sep 17 00:00:00 2001 From: ducdmdev Date: Thu, 26 Feb 2026 18:04:25 +0700 Subject: [PATCH 18/18] docs: update test count to 88 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d601a9b..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 (87 tests across 13 files) +bun test # run tests (88 tests across 13 files) bun run typecheck # tsc --noEmit bun run build # compile to dist/ ```