diff --git a/.gitignore b/.gitignore index e09b2b8..a7f5b4d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .DS_Store .agent-team/ .claude/ +.worktrees/ diff --git a/src/generators/api-fetch.ts b/src/generators/api-fetch.ts new file mode 100644 index 0000000..f70057f --- /dev/null +++ b/src/generators/api-fetch.ts @@ -0,0 +1,19 @@ +function generateApiFetch(): string { + 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, {') + 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 } diff --git a/src/generators/hooks.ts b/src/generators/hooks.ts index 663b59e..e612c19 100644 --- a/src/generators/hooks.ts +++ b/src/generators/hooks.ts @@ -181,9 +181,10 @@ function collectMockImports(ir: IR): string[] { return [...mocks] } -function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: string }): string { +function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: string; apiFetchImportPath?: string }): string { const mock = options?.mock ?? true const providerImportPath = options?.providerImportPath ?? './test-mode-provider' + const apiFetchImportPath = options?.apiFetchImportPath const parts: string[] = [] const queryOps = ir.operations.filter(op => op.method === 'get') const mutationOps = ir.operations.filter(op => op.method !== 'get') @@ -206,18 +207,23 @@ function generateHooks(ir: IR, options?: { mock?: boolean; providerImportPath?: if (typeImports.length > 0) { parts.push(`import type { ${typeImports.join(', ')} } from './types'`) } + if (apiFetchImportPath) { + parts.push(`import { apiFetch } from '${apiFetchImportPath}'`) + } parts.push('') - parts.push(`function apiFetch(path: string, init?: RequestInit): Promise {`) - 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('') + if (!apiFetchImportPath) { + parts.push(`function apiFetch(path: string, init?: RequestInit): Promise {`) + 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('') + } for (const op of queryOps) { parts.push(generateQueryHook(op, mock)) diff --git a/src/generators/index-file.ts b/src/generators/index-file.ts index 0b81556..4fb816c 100644 --- a/src/generators/index-file.ts +++ b/src/generators/index-file.ts @@ -1,5 +1,6 @@ -function generateIndexFile(options?: { mock?: boolean }): string { +function generateIndexFile(options?: { mock?: boolean; includeProvider?: boolean }): string { const mock = options?.mock ?? true + const includeProvider = options?.includeProvider ?? true const lines = [ '/* eslint-disable */', '/* This file is auto-generated by apigen. Do not edit. */', @@ -9,7 +10,9 @@ function generateIndexFile(options?: { mock?: boolean }): string { ] if (mock) { lines.push("export * from './mocks'") - lines.push("export * from './test-mode-provider'") + if (includeProvider) { + lines.push("export * from './test-mode-provider'") + } } lines.push('') return lines.join('\n') diff --git a/src/generators/mocks.ts b/src/generators/mocks.ts index 79c02a8..d294954 100644 --- a/src/generators/mocks.ts +++ b/src/generators/mocks.ts @@ -45,7 +45,16 @@ function fakerValueForField(name: string, type: string): string { if (lower === 'password' || lower === 'secret') return `'${faker.internet.password()}'` if (lower === 'avatar' || lower === 'image' || lower === 'photo' || lower === 'picture') return `'${faker.image.url()}'` if (lower === 'color' || lower === 'colour') return `'${faker.color.human()}'` - if (lower === 'createdat' || lower === 'updatedat' || lower === 'date' || lower === 'timestamp' || lower.endsWith('date') || lower.endsWith('at')) return `'${faker.date.recent().toISOString()}'` + if (lower === 'createdat' || lower === 'updatedat' || lower === 'date' || lower === 'timestamp' || lower.endsWith('date') || lower.endsWith('at')) { + if (type === 'number') return `${faker.date.recent().getTime()}` + return `'${faker.date.recent().toISOString()}'` + } + + // Handle union types by using the first variant + if (type.includes(' | ')) { + const firstType = type.split(' | ')[0].trim() + return fakerValueForField(name, firstType) + } // Type-based fallback switch (type) { diff --git a/src/ir.ts b/src/ir.ts index 249b995..ff4246e 100644 --- a/src/ir.ts +++ b/src/ir.ts @@ -63,7 +63,7 @@ function mapOpenApiType(schema: Record): string { // Zod pattern: anyOf [string, array] → treat as array const hasArray = realTypes.find(v => v.type === 'array') if (hasArray && realTypes.length === 2) return 'array' - return 'unknown' + return mapped.join(' | ') } const type = schema.type as string | undefined @@ -103,8 +103,15 @@ function generateOperationId(method: string, path: string): string { const action = actionSuffixes[lastSegment] if (action && segments.length >= 2) { - const resource = kebabToPascal(segments[segments.length - 2]) - return `${action}${resource}` + let resourceIndex = segments.length - 2 + let versionSuffix = '' + // Skip version-like segments (v1, v2, etc.) to find the real resource name + if (/^v\d+$/i.test(segments[resourceIndex]) && resourceIndex > 0) { + versionSuffix = kebabToPascal(segments[resourceIndex]) + resourceIndex-- + } + const resource = kebabToPascal(segments[resourceIndex]) + return `${action}${resource}${versionSuffix}` } // No known action suffix — use method as verb diff --git a/src/writer.ts b/src/writer.ts index 625fe58..b013345 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -6,6 +6,7 @@ import { generateHooks } from './generators/hooks' import { generateMocks } from './generators/mocks' import { generateProvider } from './generators/provider' import { generateIndexFile, generateRootIndexFile } from './generators/index-file' +import { generateApiFetch } from './generators/api-fetch' function tagSlug(tag: string): string { return tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') @@ -70,6 +71,9 @@ function writeSplit(ir: IR, outputDir: string, mock: boolean): void { writeFileSync(join(outputDir, 'test-mode-provider.tsx'), generateProvider(), 'utf8') } + // Write shared api-fetch at root + writeFileSync(join(outputDir, 'api-fetch.ts'), generateApiFetch(), 'utf8') + // Write per-tag feature folders for (const slug of tagSlugs) { const ops = groups.get(slug)! @@ -80,13 +84,13 @@ function writeSplit(ir: IR, outputDir: string, mock: boolean): void { writeFileSync(join(featureDir, 'types.ts'), generateTypes(subsetIR), 'utf8') writeFileSync( join(featureDir, 'hooks.ts'), - generateHooks(subsetIR, { mock, providerImportPath: '../test-mode-provider' }), + generateHooks(subsetIR, { mock, providerImportPath: '../test-mode-provider', apiFetchImportPath: '../api-fetch' }), 'utf8', ) if (mock) { writeFileSync(join(featureDir, 'mocks.ts'), generateMocks(subsetIR), 'utf8') } - writeFileSync(join(featureDir, 'index.ts'), generateIndexFile({ mock }), 'utf8') + writeFileSync(join(featureDir, 'index.ts'), generateIndexFile({ mock, includeProvider: false }), 'utf8') } // Write root index that re-exports all feature folders diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 74a6042..f08df32 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -150,6 +150,23 @@ describe('e2e: --split flag', () => { expect(rootIndex).toContain("export * from './posts'") expect(rootIndex).toContain("export * from './users'") expect(rootIndex).toContain("export * from './test-mode-provider'") + + // Per-tag index should NOT re-export test-mode-provider (it lives at root only) + const usersIndex = readFileSync(join(outDir, 'users', 'index.ts'), 'utf8') + expect(usersIndex).not.toContain('test-mode-provider') + expect(usersIndex).toContain("export * from './types'") + expect(usersIndex).toContain("export * from './hooks'") + expect(usersIndex).toContain("export * from './mocks'") + + // Shared api-fetch.ts at root + expect(existsSync(join(outDir, 'api-fetch.ts'))).toBe(true) + const apiFetchFile = readFileSync(join(outDir, 'api-fetch.ts'), 'utf8') + expect(apiFetchFile).toContain('export function apiFetch') + + // Per-tag hooks should import from shared api-fetch, not inline it + const usersHooksForApiFetch = readFileSync(join(outDir, 'users', 'hooks.ts'), 'utf8') + expect(usersHooksForApiFetch).toContain("from '../api-fetch'") + expect(usersHooksForApiFetch).not.toMatch(/^function apiFetch/m) } finally { rmSync(outDir, { recursive: true }) } diff --git a/tests/generators/mocks.test.ts b/tests/generators/mocks.test.ts index a772dee..da3eeb7 100644 --- a/tests/generators/mocks.test.ts +++ b/tests/generators/mocks.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { resolve } from 'path' import { loadSpec } from '../../src/loader' import { extractIR } from '../../src/ir' +import type { IR } from '../../src/ir' import { generateMocks } from '../../src/generators/mocks' describe('generateMocks', () => { @@ -57,6 +58,46 @@ describe('generateMocks', () => { expect(getByIdResponseMatches).toHaveLength(1) }) + it('returns numeric timestamp for date-named fields with number type', () => { + const ir: IR = { + operations: [], + schemas: [{ + name: 'Contract', + properties: [ + { name: 'terminationDate', type: 'number', required: true, isArray: false, itemType: null, ref: null, enumValues: null }, + { name: 'createdAt', type: 'number', required: true, isArray: false, itemType: null, ref: null, enumValues: null }, + { name: 'startDate', type: 'string', required: true, isArray: false, itemType: null, ref: null, enumValues: null }, + ], + required: ['terminationDate', 'createdAt', 'startDate'], + }], + } + const output = generateMocks(ir) + + // number-typed date fields should get numeric values, not ISO strings + expect(output).toMatch(/terminationDate: \d+/) + expect(output).not.toMatch(/terminationDate: '/) + expect(output).toMatch(/createdAt: \d+/) + expect(output).not.toMatch(/createdAt: '/) + // string-typed date field should still get ISO string + expect(output).toMatch(/startDate: '/) + }) + + it('generates mock value for union type by using first variant', () => { + const ir: IR = { + operations: [], + schemas: [{ + name: 'FlexItem', + properties: [ + { name: 'value', type: 'string | boolean', required: true, isArray: false, itemType: null, ref: null, enumValues: null }, + ], + required: ['value'], + }], + } + const output = generateMocks(ir) + // Should pick first variant (string), not fall through to 'null as unknown' + expect(output).not.toContain('null as unknown') + }) + it('generates {} for object type and null as unknown for unknown type', () => { const ir = { operations: [], diff --git a/tests/ir.test.ts b/tests/ir.test.ts index 9205047..9bb70ff 100644 --- a/tests/ir.test.ts +++ b/tests/ir.test.ts @@ -224,6 +224,52 @@ describe('extractIR', () => { expect(includeField.itemType).toBe('string') }) + it('resolves anyOf with multiple distinct types to union type', () => { + const spec = { + paths: { + '/items': { + post: { + operationId: 'createFlexItem', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + value: { anyOf: [{ type: 'string' }, { type: 'boolean' }] }, + data: { anyOf: [{ type: 'number' }, { type: 'string' }, { type: 'boolean' }] }, + }, + }, + }, + }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, + components: { schemas: {} }, + } + const ir = extractIR(spec as Record) + const bodySchema = ir.schemas.find(s => s.name === 'CreateFlexItemBody') + expect(bodySchema).toBeDefined() + expect(bodySchema!.properties.find(p => p.name === 'value')!.type).toBe('string | boolean') + expect(bodySchema!.properties.find(p => p.name === 'data')!.type).toBe('number | string | boolean') + }) + + it('skips version segment in operationId generation and appends version suffix', () => { + const spec = { + paths: { + '/masterdata/sdkrw/v2/get-by-query': { post: { responses: { '200': { description: 'ok' } } } }, + '/api/v1/users/search': { post: { responses: { '200': { description: 'ok' } } } }, + }, + components: { schemas: {} }, + } + const ir = extractIR(spec as Record) + const ids = ir.operations.map(op => op.operationId) + expect(ids).toContain('getByQuerySdkrwV2') + expect(ids).toContain('searchUsers') + }) + it('resolves anyOf nullable types to base type', () => { const spec = { paths: {