Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.DS_Store
.agent-team/
.claude/
.worktrees/
19 changes: 19 additions & 0 deletions src/generators/api-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {')
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<T>')
lines.push(' })')
lines.push('}')
lines.push('')
return lines.join('\n')
}

export { generateApiFetch }
28 changes: 17 additions & 11 deletions src/generators/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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<T>(path: string, init?: RequestInit): Promise<T> {`)
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<T>`)
parts.push(` })`)
parts.push(`}`)
parts.push('')
if (!apiFetchImportPath) {
parts.push(`function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {`)
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<T>`)
parts.push(` })`)
parts.push(`}`)
parts.push('')
}

for (const op of queryOps) {
parts.push(generateQueryHook(op, mock))
Expand Down
7 changes: 5 additions & 2 deletions src/generators/index-file.ts
Original file line number Diff line number Diff line change
@@ -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. */',
Expand All @@ -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')
Expand Down
11 changes: 10 additions & 1 deletion src/generators/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 10 additions & 3 deletions src/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function mapOpenApiType(schema: Record<string, unknown>): string {
// Zod pattern: anyOf [string, array<string>] → 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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '')
Expand Down Expand Up @@ -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)!
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down
41 changes: 41 additions & 0 deletions tests/generators/mocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: [],
Expand Down
46 changes: 46 additions & 0 deletions tests/ir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)
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<string, unknown>)
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: {
Expand Down