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
5 changes: 4 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
"fastify-type-provider-zod",
"@fastify/formbody",
"@fastify/multipart",
"@codewithagents/petstore-hono"
"@codewithagents/petstore-hono",
"hono",
"express",
"@types/express"
],
"rules": {
"unused-files": "warn",
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ reports/
*.tgz
examples/generated-server/
.astro/
.typecheck-scratch/
6 changes: 5 additions & 1 deletion packages/openapi-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,16 @@
"devDependencies": {
"@stryker-mutator/core": "catalog:",
"@stryker-mutator/vitest-runner": "catalog:",
"@types/express": "^5.0.6",
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"esbuild": "catalog:",
"express": "^5.2.1",
"fast-check": "catalog:",
"hono": "^4.12.26",
"typescript": "catalog:",
"vitest": "catalog:"
"vitest": "catalog:",
"zod": "catalog:"
},
"keywords": [
"openapi",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Typechecked guard for issue #377.
*
* compat-matrix.test.ts only asserts that generation "does not throw" — it never actually
* compiles the output, which is exactly why the hono/express explode:true array query
* param bug (a TS2322 mismatch between the emitted Zod/extraction type and the service's
* T[] signature) slipped through undetected. This file closes that gap: it feeds the real
* generated service.ts + router.ts (+ _shared/errors.ts) for a single explode:true integer
* array query param through the actual TypeScript compiler, for all three frameworks, so a
* regression here fails loudly with a real tsc diagnostic instead of a passing string match.
*
* Requires real `zod`, `hono`, `express`, and `fastify`/`fastify-type-provider-zod` type
* declarations to resolve on disk (devDependencies of this package) — compileGeneratedFiles falls
* back to real node_modules resolution for bare specifiers.
*/
import { describe, it } from 'vitest'
import type { OpenAPIV3_1 } from 'openapi-types'
import { generateService } from '../plugins/service.js'
import { generateRouter, generateExpressRouter, generateFastifyRouter } from '../plugins/router.js'
import { generateFastifyTypedService } from '../plugins/fastify-service.js'
import { emitSharedErrorsFile } from '../plugins/errors-emitter.js'
import { compileGeneratedFiles, assertNoTsDiagnostics } from './ts-compile-helpers.js'

const arrayQuerySpec: OpenAPIV3_1.Document = {
openapi: '3.1.0',
info: { title: 'Array Query Typecheck', version: '1.0.0' },
paths: {
'/items': {
get: {
operationId: 'listItems',
parameters: [
{
name: 'ids',
in: 'query',
required: false,
schema: { type: 'array', items: { type: 'integer' } },
},
],
responses: { '204': { description: 'No content' } },
},
},
},
}

const sharedErrors = emitSharedErrorsFile()

describe('explode:true numeric array query param round-trip typechecks (#377)', () => {
it('Hono: service.ts + router.ts compile with no TS errors', () => {
const service = generateService(arrayQuerySpec)
const router = generateRouter(arrayQuerySpec)
const diagnostics = compileGeneratedFiles({
'service.ts': service.content,
'router.ts': router.content,
'_shared/errors.ts': sharedErrors.content,
})
assertNoTsDiagnostics(diagnostics, 'Hono router.ts + service.ts (#377)')
})

it('Express: service.ts + router.ts compile with no TS errors', () => {
const service = generateService(arrayQuerySpec)
const router = generateExpressRouter(arrayQuerySpec)
const diagnostics = compileGeneratedFiles({
'service.ts': service.content,
'router.ts': router.content,
'_shared/errors.ts': sharedErrors.content,
})
assertNoTsDiagnostics(diagnostics, 'Express router.ts + service.ts (#377)')
})

it('Fastify: service.ts + router.ts compile with no TS errors (control: already correct pre-#377)', () => {
const fastifyOpts = { schemaNames: new Set<string>(), schemaImportPath: './schemas.js' }
const service = generateFastifyTypedService(arrayQuerySpec, fastifyOpts)
const router = generateFastifyRouter(arrayQuerySpec, fastifyOpts)
const diagnostics = compileGeneratedFiles({
'service.ts': service.content,
'router.ts': router.content,
'_shared/errors.ts': sharedErrors.content,
})
assertNoTsDiagnostics(diagnostics, 'Fastify router.ts + service.ts (#377 control)')
})

it('sanity check: compileGeneratedFiles actually surfaces a diagnostic for a genuine type mismatch', () => {
// Proves the guard above is not vacuously green: a hand-rolled service/router pair with
// the SAME shape of bug this issue fixes (scalar passed where T[] is expected) must fail.
const service = 'export interface X { m(params?: { ids?: number[] }): Promise<void> }\n'
const router =
"import type { X } from './service.js'\n" +
'declare const service: X\n' +
"const params: { ids: string } = { ids: '1' }\n" +
'service.m(params)\n'
const diagnostics = compileGeneratedFiles({ 'service.ts': service, 'router.ts': router })
if (diagnostics.length === 0) {
throw new Error(
'expected a TS diagnostic for the deliberate ids: string vs number[] mismatch'
)
}
})
})
171 changes: 171 additions & 0 deletions packages/openapi-server/src/__tests__/hono-express-array-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Regression tests for issue #377: explode:true array query params must emit
* z.array(<itemExpr>) plus repeated-key extraction in the Hono and Express routers,
* not the scalar z.string() fallback that #375/#348 already fixed for Fastify.
*
* Before the fix, a query param with type:array (default explode:true) fell through to
* z.string() in the emitted Zod schema, and extraction read a single scalar value
* (c.req.query / req.query['name']), while the generated service method expects an
* array type (number[] / boolean[] / string[]). Result: a TS2322 type mismatch in the
* generated output. After the fix, extraction collects every repeated key and coerces
* items to match z.array(<itemExpr>).
*/
import { describe, expect, it } from 'vitest'
import type { OpenAPIV3_1 } from 'openapi-types'
import { generateRouter, generateExpressRouter } from '../plugins/router.js'

function makeSpec(paths: OpenAPIV3_1.PathsObject): OpenAPIV3_1.Document {
return { openapi: '3.1.0', info: { title: 'Test API', version: '1.0.0' }, paths }
}

function arrayQuerySpec(
itemType: 'integer' | 'number' | 'boolean' | 'string'
): OpenAPIV3_1.Document {
return makeSpec({
'/items': {
get: {
operationId: 'getItems',
parameters: [
{
name: 'ids',
in: 'query',
required: false,
schema: { type: 'array', items: { type: itemType } },
},
],
responses: { '200': { description: 'ok' } },
},
},
})
}

const requiredArraySpec = makeSpec({
'/items': {
get: {
operationId: 'getItems',
parameters: [
{
name: 'ids',
in: 'query',
required: true,
schema: { type: 'array', items: { type: 'integer' } },
},
],
responses: { '200': { description: 'ok' } },
},
},
})

describe('generateRouter (Hono): explode:true array query params (#377)', () => {
it('integer array emits z.array(z.coerce.number()) and c.req.queries().map(Number)', () => {
const { content } = generateRouter(arrayQuerySpec('integer'))
expect(content).toContain('z.array(z.coerce.number())')
expect(content).toContain("c.req.queries('ids')?.map(Number)")
// Must NOT fall through to a bare scalar read/schema.
expect(content).not.toMatch(/ids:\s*z\.string\(\)/)
expect(content).not.toContain("c.req.query('ids')")
})

it('boolean array emits z.array(z.boolean()) and coerces items via === "true"', () => {
const { content } = generateRouter(arrayQuerySpec('boolean'))
expect(content).toContain('z.array(z.boolean())')
expect(content).toContain("c.req.queries('ids')?.map((v) => v === 'true')")
})

it('string array emits z.array(z.string()) and collects repeated keys with no item coercion', () => {
const { content } = generateRouter(arrayQuerySpec('string'))
expect(content).toContain('z.array(z.string())')
expect(content).toContain("ids: c.req.queries('ids')")
expect(content).not.toContain("c.req.queries('ids')?.map(")
})
})

describe('generateExpressRouter: explode:true array query params (#377)', () => {
it('integer array emits z.array(z.coerce.number()) and a normalized, mapped extraction', () => {
const { content } = generateExpressRouter(arrayQuerySpec('integer'))
expect(content).toContain('z.array(z.coerce.number())')
expect(content).toContain(
"_toQueryArray(req.query['ids'] as string | string[] | undefined)?.map(Number)"
)
expect(content).not.toMatch(/ids:\s*z\.string\(\)/)
expect(content).not.toContain("Number(req.query['ids'] as string)")
})

it('boolean array emits z.array(z.boolean()) and coerces items via === "true"', () => {
const { content } = generateExpressRouter(arrayQuerySpec('boolean'))
expect(content).toContain('z.array(z.boolean())')
expect(content).toContain(
"_toQueryArray(req.query['ids'] as string | string[] | undefined)?.map((v) => v === 'true')"
)
})

it('string array emits z.array(z.string()) and the normalized array with no item coercion', () => {
const { content } = generateExpressRouter(arrayQuerySpec('string'))
expect(content).toContain('z.array(z.string())')
expect(content).toContain(
"ids: _toQueryArray(req.query['ids'] as string | string[] | undefined)"
)
expect(content).not.toContain('?.map(')
})

it('emits the shared _toQueryArray normalizer once, only when an array param exists', () => {
const { content } = generateExpressRouter(arrayQuerySpec('integer'))
const matches = content.match(/function _toQueryArray/g) ?? []
expect(matches).toHaveLength(1)
})

it('does NOT emit _toQueryArray when no array query param exists', () => {
const spec = makeSpec({
'/items': {
get: {
operationId: 'getItems',
parameters: [{ name: 'q', in: 'query', required: false, schema: { type: 'string' } }],
responses: { '200': { description: 'ok' } },
},
},
})
const { content } = generateExpressRouter(spec)
expect(content).not.toContain('_toQueryArray')
})
})

describe('required array param omits .optional() on the z.array(...) expression', () => {
it.each([
['Hono', generateRouter],
['Express', generateExpressRouter],
] as const)('%s', (_, gen) => {
const { content } = gen(requiredArraySpec)
expect(content).toContain('z.array(z.coerce.number())')
expect(content).not.toMatch(/ids:\s*z\.array\(z\.coerce\.number\(\)\)\.optional\(\)/)
})
})

describe('Hono and Express agree with the existing Fastify fix (#375/#348) on service query type', () => {
it.each([
['Hono', generateRouter],
['Express', generateExpressRouter],
] as const)('%s: does not disturb the delimited (explode:false) array path', (_, gen) => {
const spec = makeSpec({
'/items': {
get: {
operationId: 'getItems',
parameters: [
{
name: 'csv',
in: 'query',
required: true,
style: 'form',
explode: false,
schema: { type: 'array', items: { type: 'integer' } },
} as OpenAPIV3_1.ParameterObject,
],
responses: { '200': { description: 'ok' } },
},
},
})
const { content } = gen(spec)
// Delimited arrays are untouched by the isArray branch: still split + z.array(z.string()).
expect(content).toContain('.split(",")')
expect(content).toContain('z.array(z.string())')
})
})
87 changes: 87 additions & 0 deletions packages/openapi-server/src/__tests__/ts-compile-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* TypeScript-compiler-API helper for tests that need to verify generated output actually
* TYPECHECKS, not just that it contains the right substrings. Complements the in-memory
* compileFiles helper in packages/openapi-zod-ts/src/__tests__/helpers.ts (not imported
* directly: it lives in a sibling package's test-only sources, outside its public API, and
* that helper never needed real node_modules resolution since openapi-zod-ts client output
* has zero runtime deps).
*
* Unlike an in-memory virtual filesystem, this writes files to a REAL temp directory nested
* inside this package (not os.tmpdir()) so that Node's module resolution, walking up from
* the temp directory, finds this package's real node_modules (zod, hono, express, fastify,
* fastify-type-provider-zod) and resolves their real .d.ts files.
*
* Import with the .js extension as required by NodeNext module resolution:
* import { compileGeneratedFiles } from './ts-compile-helpers.js'
*/
import ts from 'typescript'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'

/** Directory that holds per-test scratch dirs; sibling of node_modules so resolution walks up into it. */
const scratchRoot = join(import.meta.dirname, '../../.typecheck-scratch')

/**
* Write `files` (keyed by relative path, e.g. 'router.ts', '_shared/errors.ts') into a fresh
* temp directory under this package, compile them with the real TypeScript compiler, and
* return only the diagnostics that belong to those files (node_modules/lib.d.ts excluded).
* Always cleans up the temp directory before returning, even on failure.
*/
export function compileGeneratedFiles(files: Record<string, string>): readonly ts.Diagnostic[] {
mkdirSync(scratchRoot, { recursive: true })
const dir = mkdtempSync(join(scratchRoot, 'run-'))
try {
const fileNames: string[] = []
for (const [name, content] of Object.entries(files)) {
const filePath = join(dir, name)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, content, 'utf-8')
fileNames.push(filePath)
}

const { options } = ts.convertCompilerOptionsFromJson(
{
strict: true,
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'Bundler',
noEmit: true,
skipLibCheck: true,
lib: ['ES2022', 'DOM'],
},
dir
)
Comment on lines +42 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Compiler-options parsing errors are silently discarded.

ts.convertCompilerOptionsFromJson returns { options, errors }; only options is destructured, so a typo or invalid option value in the hardcoded config would be swallowed instead of surfacing, potentially causing the compile to silently run with unintended defaults.

🛡️ Proposed fix
-    const { options } = ts.convertCompilerOptionsFromJson(
+    const { options, errors } = ts.convertCompilerOptionsFromJson(
       {
         strict: true,
         target: 'ES2022',
         module: 'ESNext',
         moduleResolution: 'Bundler',
         noEmit: true,
         skipLibCheck: true,
         lib: ['ES2022', 'DOM'],
       },
       dir
     )
+    if (errors.length > 0) {
+      throw new Error(`Invalid compiler options: ${errors.map((e) => e.messageText).join('\n')}`)
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { options } = ts.convertCompilerOptionsFromJson(
{
strict: true,
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'Bundler',
noEmit: true,
skipLibCheck: true,
lib: ['ES2022', 'DOM'],
},
dir
)
const { options, errors } = ts.convertCompilerOptionsFromJson(
{
strict: true,
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'Bundler',
noEmit: true,
skipLibCheck: true,
lib: ['ES2022', 'DOM'],
},
dir
)
if (errors.length > 0) {
throw new Error(`Invalid compiler options: ${errors.map((e) => e.messageText).join('\n')}`)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/openapi-server/src/__tests__/ts-compile-helpers.ts` around lines 42
- 53, The TypeScript compiler options parsing in ts-compile-helpers is ignoring
the errors returned by ts.convertCompilerOptionsFromJson, so invalid hardcoded
options can be silently accepted. Update the helper that builds compiler options
to capture both options and errors, and make it fail or report when errors are
present instead of proceeding with only options. Keep the change localized to
the ts.convertCompilerOptionsFromJson call site so any bad config in the test
helper surfaces immediately.


const program = ts.createProgram(fileNames, options)
return ts
.getPreEmitDiagnostics(program)
.filter((d) => d.file !== undefined && fileNames.includes(d.file.fileName))
} finally {
rmSync(dir, { recursive: true, force: true })
}
}

/**
* Assert that a TypeScript compilation produced no diagnostics. Throws a descriptive
* error listing all messages when diagnostics are present.
*
* @param diagnostics - result from compileGeneratedFiles
* @param context - short label for the error message, e.g. "hono router + service (#377)"
*/
export function assertNoTsDiagnostics(
diagnostics: readonly ts.Diagnostic[],
context: string
): void {
if (diagnostics.length > 0) {
const messages = diagnostics
.map((d) => {
const loc =
d.file !== undefined ? d.file.getLineAndCharacterOfPosition(d.start!) : undefined
const at =
loc !== undefined ? `${d.file!.fileName}:${loc.line + 1}:${loc.character + 1}` : ''
return `${at} ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`
})
.join('\n')
throw new Error(`TypeScript errors in ${context}:\n${messages}`)
}
}
Loading
Loading