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
9 changes: 9 additions & 0 deletions .changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"changes": {
"packages/generator/package.json": "Patch",
"packages/fetch/package.json": "Patch",
"packages/utils/package.json": "Patch"
},
"note": "Preserve nullable referenced enum and object types, omit unused imports from generated server actions, restore filesystem test spies, and verify request defaults and empty-schema generation with Bun 1.4.4 coverage",
"date": "2026-11-04T17:39:34.411812800Z"
}
12 changes: 12 additions & 0 deletions packages/fetch/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ afterEach(() => {
globalThis.fetch = originalFetch
})

test('request defaults to GET when no method is specified', async () => {
const api = new DevupApi('https://api.example.com', undefined, 'openapi.json')
const fetcher = globalThis.fetch as unknown as ReturnType<typeof mock>

await api.request('/test' as never)

expect(fetcher).toHaveBeenCalledTimes(1)
const request = fetcher.mock.calls[0]?.[0] as Request
expect(request.method).toBe('GET')
expect(request.url).toBe('https://api.example.com/test')
})

test.each([
['https://api.example.com', 'https://api.example.com'],
['https://api.example.com/', 'https://api.example.com'],
Expand Down
8 changes: 8 additions & 0 deletions packages/generator/src/__tests__/generate-interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { expect, test } from 'bun:test'
import type { OpenAPIV3_1 } from 'openapi-types'
import { generateInterface } from '../generate-interface'

test('omits precomputed mappings when no OpenAPI documents are supplied', () => {
const output = generateInterface({})

expect(output).toContain('interface DevupApiServers')
expect(output).not.toContain('interface DevupPrecomputedMethodKeys')
expect(output).not.toContain('interface DevupPrecomputedScopes')
})

const createDocument = (
document: Partial<OpenAPIV3_1.Document> = {},
): OpenAPIV3_1.Document =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ describe('generateServerActionCode', () => {

expect(result).toContain("'use server'")
expect(result).toContain('export async function getUsers')
expect(result).toContain('DevupGetApiStructScope')
expect(result).not.toContain('DevupPostApiStructScope')
})

test('does not generate actions when serverActions is explicitly disabled', () => {
Expand All @@ -90,6 +92,8 @@ describe('generateServerActionCode', () => {

expect(result).toContain("'use server'")
expect(result).not.toContain('export async function getUsers')
expect(result).not.toContain('import ')
expect(result).toContain('export {}')
})

test('does not generate actions when serverActions object is disabled', () => {
Expand All @@ -111,6 +115,8 @@ describe('generateServerActionCode', () => {

expect(result).toContain("'use server'")
expect(result).not.toContain('export async function getUsers')
expect(result).not.toContain('import ')
expect(result).toContain('export {}')
})

test('generates all operationIds with empty serverActions config', () => {
Expand Down
165 changes: 165 additions & 0 deletions packages/generator/src/__tests__/generated-client-compile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, test } from 'bun:test'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmdirSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import type { DevupApiOptions } from '@devup-api/core'
import type { OpenAPIV3_1 } from 'openapi-types'
import { generateInterface } from '../generate-interface'
import {
generateServerActionCode,
generateServerActionTypes,
} from '../generate-server-actions'

const require = createRequire(import.meta.url)
const typescriptManifest = require.resolve('typescript/package.json')
const typescript = JSON.parse(readFileSync(typescriptManifest, 'utf8'))
const compiler = path.resolve(
path.dirname(typescriptManifest),
typescript.bin.tsc,
)
// Resolve @devup-api/fetch through its package self-reference in the fixture.
const cacheRoot = path.resolve(import.meta.dirname, '../../../fetch/.cache')

function checkGeneratedClient(options?: DevupApiOptions, operationId = true) {
const nullable = {
anyOf: [
{ $ref: '#/components/schemas/ServiceType' },
{ type: 'null' as const },
],
}
const schemas: Record<string, OpenAPIV3_1.Document> = {
'openapi.json': {
openapi: '3.1.0',
info: { title: 'Nullable request', version: '1.0.0' },
paths: {
'/inputs': {
post: {
...(operationId ? { operationId: 'createInput' } : {}),
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Input' },
},
},
},
responses: { '200': { description: 'Success' } },
},
},
},
components: {
schemas: {
ServiceType: { type: 'string', enum: ['B', 'E'] },
Input: {
type: 'object',
required: ['requiredServiceType', 'nonNullable'],
properties: {
serviceType: nullable,
requiredServiceType: nullable,
nonNullable: { $ref: '#/components/schemas/ServiceType' },
},
},
},
},
},
}
mkdirSync(cacheRoot, { recursive: true })
const directory = mkdtempSync(path.join(cacheRoot, 'generated-client-'))
const files = {
'api.d.ts': generateInterface(schemas, options),
'server.ts': generateServerActionCode(schemas, options),
'server-module.d.ts': generateServerActionTypes(schemas, options),
'client.ts': `import { createApi, type DevupObject } from '@devup-api/fetch'
type Input = DevupObject<'request'>['Input']
export const input: Input = { serviceType: null, requiredServiceType: null, nonNullable: 'B' }
export const api = createApi('https://example.invalid')
export function checkTypes() {
void api.post('/inputs', { body: input })
void api.post('/inputs', { body: { requiredServiceType: 'E', nonNullable: 'B' } })
// @ts-expect-error values outside the enum must remain invalid
const badValue: Input = { ...input, serviceType: 'INVALID' }
// @ts-expect-error a required nullable field must still be present
const missing: Input = { nonNullable: 'B' }
// @ts-expect-error null must remain invalid on non-nullable references
const badNull: Input = { ...input, nonNullable: null }
void [badValue, missing, badNull]
}
`,
'runtime.ts': `import { expect, spyOn } from 'bun:test'
import { api, input } from './client'
const fetcher = spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true }))
await api.post('/inputs', { body: input })
expect(fetcher).toHaveBeenCalledTimes(1)
const request = fetcher.mock.calls[0]?.[0]
if (!(request instanceof Request)) throw new Error('Expected a Request')
expect(request.url).toBe('https://example.invalid/inputs')
expect(await request.json()).toEqual({ serviceType: null, requiredServiceType: null, nonNullable: 'B' })
fetcher.mockRestore()
`,
'tsconfig.json': JSON.stringify({
compilerOptions: {
target: 'ESNext',
module: 'ESNext',
moduleResolution: 'bundler',
lib: ['ESNext', 'DOM'],
types: [],
strict: true,
noEmit: true,
noUnusedLocals: true,
noUnusedParameters: true,
skipLibCheck: true,
},
files: ['api.d.ts', 'client.ts', 'server.ts', 'server-module.d.ts'],
}),
}
try {
for (const [file, content] of Object.entries(files)) {
writeFileSync(path.join(directory, file), content)
}
for (const command of [
[
process.execPath,
compiler,
'--project',
path.join(directory, 'tsconfig.json'),
],
[process.execPath, path.join(directory, 'runtime.ts')],
]) {
const result = Bun.spawnSync(command, { cwd: directory, timeout: 20_000 })
if (result.exitCode !== 0) {
throw new Error(
`${result.stdout.toString()}\n${result.stderr.toString()}`,
)
}
}
} finally {
for (const file of Object.keys(files)) {
const target = path.join(directory, file)
if (existsSync(target)) unlinkSync(target)
}
rmdirSync(directory)
}
}

describe('generated client compatibility', () => {
test.each([false, { enabled: false }] as const)(
'compiles disabled server actions without unused imports: %j',
(serverActions) => checkGeneratedClient({ serverActions }),
)

test('compiles generated actions with only the used method scopes', () => {
checkGeneratedClient()
})

test('compiles an empty action module when operations have no operationId', () => {
checkGeneratedClient(undefined, false)
})
})
142 changes: 142 additions & 0 deletions packages/generator/src/__tests__/nullable-ref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, test } from 'bun:test'
import type { OpenAPIV3_1 } from 'openapi-types'
import { generateInterface } from '../generate-interface'
import {
createSchemaContext,
formatTypeValue,
getTypeFromSchema,
} from '../generate-schema'

const reference = { $ref: '#/components/schemas/ServiceType' }
const nullable = { anyOf: [reference, { type: 'null' as const }] }

const document: OpenAPIV3_1.Document = {
openapi: '3.1.0',
info: { title: 'Nullable enum reference', version: '1.0.0' },
paths: {
'/inputs': {
post: {
requestBody: {
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Input' },
},
},
},
responses: {
'200': {
description: 'Success',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Input' },
},
},
},
},
},
},
},
components: {
schemas: {
ServiceType: { type: 'string', enum: ['B', 'E'] },
Item: { type: 'object', properties: { id: { type: 'string' } } },
Input: {
type: 'object',
required: ['requiredServiceType'],
properties: {
serviceType: nullable,
requiredServiceType: nullable,
nonNullable: reference,
},
},
},
},
}

describe('nullable references', () => {
test('uses the referenced enum name without leaking the caller context', () => {
const context = createSchemaContext('Wrapper')
context.propertyPath.push('parent')

const result = getTypeFromSchema(nullable, document, {
context,
propertyName: 'selected',
})

expect(formatTypeValue(result.type)).toBe('ServiceType | null')
expect(context.schemaName).toBe('Wrapper')
expect(context.propertyPath).toEqual(['parent'])
expect([...context.enums.keys()]).toEqual(['ServiceType'])
expect(context.enums.get('ServiceType')?.nullable).toBe(true)
})

test.each([false, true])(
'preserves optional and required enum nullability with requestDefaultNonNullable=%s',
(requestDefaultNonNullable) => {
const output = generateInterface(
{ 'openapi.json': document },
{ requestDefaultNonNullable },
)
// Both request and response component types must preserve the contract.
expect(output.match(/serviceType\?: ServiceType \| null/g)).toHaveLength(
2,
)
expect(
output.match(/requiredServiceType: ServiceType \| null/g),
).toHaveLength(2)
expect(output.match(/nonNullable\?: ServiceType;/g)).toHaveLength(2)
},
)

test.each(['anyOf', 'oneOf'] as const)(
'preserves %s nullability when resolving a reference inline',
(keyword) => {
const { type } = getTypeFromSchema(
{ [keyword]: [reference, { type: 'null' }] },
document,
)
expect(formatTypeValue(type)).toBe('"B" | "E" | null')
},
)

for (const componentType of ['request', 'response', 'error'] as const) {
test(`${componentType} references preserve enum and object nullability`, () => {
const options = {
serverName: 'openapi.json',
componentType,
usedSchemaNames: new Set(['ServiceType', 'Item']),
}
expect(
formatTypeValue(getTypeFromSchema(nullable, document, options).type),
).toBe('ServiceType | null')
expect(
formatTypeValue(
getTypeFromSchema(
{
oneOf: [{ $ref: '#/components/schemas/Item' }, { type: 'null' }],
},
document,
options,
).type,
),
).toBe(`DevupObject<'${componentType}', 'openapi.json'>['Item'] | null`)
})
}

test('preserves nullability declared on a referenced enum', () => {
const schema = { type: 'string' as const, enum: ['B', 'E'], nullable: true }
const result = getTypeFromSchema(
reference,
{
...document,
components: { schemas: { ServiceType: schema } },
},
{
serverName: 'openapi.json',
componentType: 'request',
usedSchemaNames: new Set(['ServiceType']),
},
)
expect(formatTypeValue(result.type)).toBe('ServiceType | null')
})
})
Loading
Loading