From 31cfe688979d9118609c1f5a241e504eb1205ef1 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:09:17 +0200 Subject: [PATCH] fix(openapi-server): handle explode:true array query params in hono/express routers The hono and express router emitters had no branch for explode:true array query params (type: array with the default explode). Such a param fell through to z.string() in the emitted Zod schema, and extraction read a single scalar value, while the generated service method expects an array type (number[] / boolean[] / string[]). This produced a TS2322 type mismatch in the generated output. The Fastify emitter already handled this correctly (#375/#348); this closes the same gap for hono and express. router.ts changes: - queryParamZodExpr: new isArray branch emits z.array(), mirroring the Fastify emitter's item coercion (number -> z.coerce.number(), boolean -> z.boolean(), else z.string()). - Hono extraction: c.req.queries(name) collects every repeated key into string[] | undefined, then items are coerced (.map(Number) / .map(v => v === 'true')) to match the Zod validation. - Express extraction: qs normalizes a repeated key to string[], but a single occurrence stays a bare string and an absent key is undefined. A small shared _toQueryArray(...) helper (emitted once per file, only when needed) normalizes all three cases before the same item coercion. Adds a typechecked guard: compat-matrix.test.ts only asserts generation "does not throw", never that the output actually compiles, which is why this slipped through. array-query-typecheck.test.ts feeds the real generated service.ts + router.ts through the TypeScript compiler API for an explode:true integer array query param, across hono, express, and fastify (as a control), plus a sanity check proving the harness itself catches a genuine type mismatch. Requires real zod/hono/express/fastify type declarations on disk, so those are added as devDependencies here. hono-express-array-query.test.ts adds string-level regression tests for the emitted Zod expressions and extraction snippets across integer/ boolean/string array items and required/optional variants. Closes #377 --- .fallowrc.json | 5 +- .gitignore | 1 + packages/openapi-server/package.json | 6 +- .../__tests__/array-query-typecheck.test.ts | 98 ++++++++++ .../hono-express-array-query.test.ts | 171 ++++++++++++++++++ .../src/__tests__/ts-compile-helpers.ts | 87 +++++++++ packages/openapi-server/src/plugins/router.ts | 65 +++++++ packages/openapi-server/src/plugins/shared.ts | 69 +++++-- pnpm-lock.yaml | 12 ++ 9 files changed, 498 insertions(+), 16 deletions(-) create mode 100644 packages/openapi-server/src/__tests__/array-query-typecheck.test.ts create mode 100644 packages/openapi-server/src/__tests__/hono-express-array-query.test.ts create mode 100644 packages/openapi-server/src/__tests__/ts-compile-helpers.ts diff --git a/.fallowrc.json b/.fallowrc.json index 5ec29440..23c92aab 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -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", diff --git a/.gitignore b/.gitignore index b1e1ca5f..8ad5e209 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ reports/ *.tgz examples/generated-server/ .astro/ +.typecheck-scratch/ diff --git a/packages/openapi-server/package.json b/packages/openapi-server/package.json index 1a1c2e45..4e59796f 100644 --- a/packages/openapi-server/package.json +++ b/packages/openapi-server/package.json @@ -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", diff --git a/packages/openapi-server/src/__tests__/array-query-typecheck.test.ts b/packages/openapi-server/src/__tests__/array-query-typecheck.test.ts new file mode 100644 index 00000000..5efd2f91 --- /dev/null +++ b/packages/openapi-server/src/__tests__/array-query-typecheck.test.ts @@ -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(), 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 }\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' + ) + } + }) +}) diff --git a/packages/openapi-server/src/__tests__/hono-express-array-query.test.ts b/packages/openapi-server/src/__tests__/hono-express-array-query.test.ts new file mode 100644 index 00000000..d9cf629c --- /dev/null +++ b/packages/openapi-server/src/__tests__/hono-express-array-query.test.ts @@ -0,0 +1,171 @@ +/** + * Regression tests for issue #377: explode:true array query params must emit + * z.array() 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(). + */ +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())') + }) +}) diff --git a/packages/openapi-server/src/__tests__/ts-compile-helpers.ts b/packages/openapi-server/src/__tests__/ts-compile-helpers.ts new file mode 100644 index 00000000..feb731bf --- /dev/null +++ b/packages/openapi-server/src/__tests__/ts-compile-helpers.ts @@ -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): 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 + ) + + 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}`) + } +} diff --git a/packages/openapi-server/src/plugins/router.ts b/packages/openapi-server/src/plugins/router.ts index cc0877ad..7d8fd2d6 100644 --- a/packages/openapi-server/src/plugins/router.ts +++ b/packages/openapi-server/src/plugins/router.ts @@ -133,6 +133,21 @@ function queryParamDeepObjectZodBase(param: QueryParam): string { return `z.object({ ${propFields.join(', ')} })` } +/** Coerce a single array item's Zod expression based on its item type. Mirrors the + * Fastify emitter's queryParamItemExpr so hono/express validate the same shape. */ +function queryParamItemZodExpr(itemsTsType: string | undefined): string { + if (itemsTsType === 'number') return 'z.coerce.number()' + if (itemsTsType === 'boolean') return 'z.boolean()' + return 'z.string()' +} + +/** Plain repeated-key array param (type:array, explode:true): value has been collected + * into an array by the extraction layer; emits z.array() so the querystring + * schema matches the service T[] signature instead of falling through to z.string(). */ +function queryParamArrayZodBase(param: QueryParam): string { + return `z.array(${queryParamItemZodExpr(param.itemsTsType)})` +} + /** Number/integer param: z.coerce.number() with optional range modifiers. * Uses coerce so that Fastify's raw string values (fast-querystring never converts types) * are accepted alongside the already-coerced numbers from Express/Hono extraction. */ @@ -166,6 +181,7 @@ function queryParamStringZodBase(param: QueryParam): string { * String types use z.string() with optional format/enum/pattern/length modifiers. * Delimited array params use z.array(z.string()). * DeepObject params use z.object({...}) with per-property coercion. + * Plain repeated-key array params (explode:true) use z.array(). * Appends .optional() for non-required params. */ function queryParamZodExpr(param: QueryParam): string { @@ -174,6 +190,8 @@ function queryParamZodExpr(param: QueryParam): string { base = queryParamDelimitedZodBase(param) } else if (param.isDeepObject === true && param.deepObjectProperties !== undefined) { base = queryParamDeepObjectZodBase(param) + } else if (param.isArray === true) { + base = queryParamArrayZodBase(param) } else if (param.tsType === 'number') { base = queryParamNumberZodBase(param) } else if (param.tsType === 'boolean') { @@ -289,6 +307,33 @@ function delimiterChar(style: 'csv' | 'ssv' | 'psv'): string { return ',' } +/** + * Build the Hono extraction expression for a plain repeated-key array query param + * (explode:true). c.req.queries(name) collects every occurrence of the key into + * string[] | undefined; items are coerced to match queryParamArrayZodBase's item + * expression so the extracted value and the Zod validation agree on the runtime type. + */ +function honoArrayQueryExpr(q: QueryParam): string { + const base = `c.req.queries('${q.rawName}')` + if (q.itemsTsType === 'number') return `${base}?.map(Number)` + if (q.itemsTsType === 'boolean') return `${base}?.map((v) => v === 'true')` + return base +} + +/** + * Build the Express extraction expression for a plain repeated-key array query param + * (explode:true). Express (via qs) gives a REPEATED key (?ids=1&ids=2) as string[], but a + * SINGLE occurrence (?ids=1) as a bare string and an absent key as undefined; _toQueryArray + * (emitted once per file, see generateExpressRouter) normalizes all three cases to + * string[] | undefined before item coercion, mirroring queryParamArrayZodBase's item expression. + */ +function expressArrayQueryExpr(q: QueryParam): string { + const base = `_toQueryArray(req.query['${q.rawName}'] as string | string[] | undefined)` + if (q.itemsTsType === 'number') return `${base}?.map(Number)` + if (q.itemsTsType === 'boolean') return `${base}?.map((v) => v === 'true')` + return base +} + /** * Emit Zod validation lines for query parameters into the handler line buffer. * Uses the already-extracted params object (after Number() coercion). @@ -715,6 +760,9 @@ function buildRouteHandler( const delim = JSON.stringify(delimiterChar(q.delimiterStyle)) return ` ${q.name}: c.req.query('${q.rawName}') !== undefined ? c.req.query('${q.rawName}')!.split(${delim}) : undefined` } + if (q.isArray === true) { + return ` ${q.name}: ${honoArrayQueryExpr(q)}` + } if (q.tsType === 'number') { return ` ${q.name}: c.req.query('${q.name}') !== undefined ? Number(c.req.query('${q.name}')) : undefined` } @@ -938,6 +986,9 @@ function buildExpressRouteHandler( const delim = JSON.stringify(delimiterChar(q.delimiterStyle)) return ` ${q.name}: typeof req.query['${q.rawName}'] === 'string' ? (req.query['${q.rawName}'] as string).split(${delim}) : undefined` } + if (q.isArray === true) { + return ` ${q.name}: ${expressArrayQueryExpr(q)}` + } if (q.tsType === 'number') { return ` ${q.name}: Number(req.query['${q.name}'] as string)` } @@ -1154,6 +1205,20 @@ export function generateExpressRouter( lines.push(`import { HttpError } from '${expressErrorsPath}'`) lines.push(`export { HttpError } from '${expressErrorsPath}'`) lines.push('') + + // Plain repeated-key array query params (explode:true) need this normalizer: qs gives a + // repeated key (?ids=1&ids=2) as string[], but a single occurrence (?ids=1) as a bare + // string and an absent key as undefined. Emitted once per file, only when needed. + const needsArrayQueryHelper = operations.some((op) => + op.queryParams.some((q) => q.isArray === true) + ) + if (needsArrayQueryHelper) { + lines.push('function _toQueryArray(v: string | string[] | undefined): string[] | undefined {') + lines.push(' return v === undefined ? undefined : Array.isArray(v) ? v : [v]') + lines.push('}') + lines.push('') + } + lines.push(`export function createRouter(service: ${serviceRef}): Router {`) lines.push(' const router = Router()') lines.push('') diff --git a/packages/openapi-server/src/plugins/shared.ts b/packages/openapi-server/src/plugins/shared.ts index 12046363..15b082a9 100644 --- a/packages/openapi-server/src/plugins/shared.ts +++ b/packages/openapi-server/src/plugins/shared.ts @@ -292,11 +292,9 @@ function applyArrayStyle( const arraySchema = schema as OpenAPIV3_1.ArraySchemaObject const items = arraySchema.items param.itemsTsType = !isRef(items) ? schemaToTsType(items as OpenAPIV3_1.SchemaObject) : 'string' - // Align the service query type with the Fastify router's z.array() inference (#375, #378): - // number/integer items -> number[], boolean -> boolean[], everything else -> string[]. NOTE: - // the hono/express routers do not yet emit array querystring handling for explode:true params - // (they extract a single string), a pre-existing gap tracked in #377, so this element type - // only round-trips cleanly on the Fastify target. + // Align the service query type with the router's z.array() inference (#375, #377, #378): + // number/integer items -> number[], boolean -> boolean[], everything else -> string[]. This + // element type round-trips cleanly on all three router targets (Fastify, Hono, Express). param.tsType = `${queryArrayItemTsType(param.itemsTsType)}[]` } @@ -309,7 +307,10 @@ function applyArrayStyle( * artefact because CI runs fallow audit without coverage data. */ // fallow-ignore-next-line complexity -function applyScalarConstraints(param: QueryParam, schema: OpenAPIV3_1.SchemaObject | undefined): void { +function applyScalarConstraints( + param: QueryParam, + schema: OpenAPIV3_1.SchemaObject | undefined +): void { if (schema === undefined || isRef(schema)) return const s = schema as OpenAPIV3_1.SchemaObject & { exclusiveMinimum?: number | boolean @@ -442,7 +443,12 @@ export function getBodyInfo( const requestBody = operation.requestBody as RequestBodyObject | ReferenceObject | undefined if (requestBody === undefined) return undefined if (isRef(requestBody)) { - return { typeName: undefined, writableTypeName: undefined, contentType: 'application/json', isSynthesized: false } + return { + typeName: undefined, + writableTypeName: undefined, + contentType: 'application/json', + isSynthesized: false, + } } const rb = requestBody as RequestBodyObject @@ -450,7 +456,12 @@ export function getBodyInfo( | Record | undefined if (content === undefined) { - return { typeName: undefined, writableTypeName: undefined, contentType: 'application/json', isSynthesized: false } + return { + typeName: undefined, + writableTypeName: undefined, + contentType: 'application/json', + isSynthesized: false, + } } // Check application/json first. @@ -475,9 +486,19 @@ export function getBodyInfo( // can wire safeParse against a user-defined schema in schemas.ts. const operationId = operation.operationId if (operationId !== undefined && operationId.length > 0) { - return { typeName: toTypeName(operationId), writableTypeName: undefined, contentType: 'application/json', isSynthesized: true } + return { + typeName: toTypeName(operationId), + writableTypeName: undefined, + contentType: 'application/json', + isSynthesized: true, + } + } + return { + typeName: undefined, + writableTypeName: undefined, + contentType: 'application/json', + isSynthesized: false, } - return { typeName: undefined, writableTypeName: undefined, contentType: 'application/json', isSynthesized: false } } // Check application/x-www-form-urlencoded. @@ -508,7 +529,12 @@ export function getBodyInfo( isSynthesized: true, } } - return { typeName: undefined, writableTypeName: undefined, contentType: 'application/x-www-form-urlencoded', isSynthesized: false } + return { + typeName: undefined, + writableTypeName: undefined, + contentType: 'application/x-www-form-urlencoded', + isSynthesized: false, + } } // Check multipart/form-data. @@ -533,14 +559,29 @@ export function getBodyInfo( isSynthesized: true, } } - return { typeName: undefined, writableTypeName: undefined, contentType: 'multipart/form-data', isSynthesized: false } + return { + typeName: undefined, + writableTypeName: undefined, + contentType: 'multipart/form-data', + isSynthesized: false, + } } // Check application/octet-stream request body. const octetContent = content['application/octet-stream'] if (octetContent !== undefined) { - return { typeName: undefined, writableTypeName: undefined, contentType: 'application/octet-stream', isSynthesized: false } + return { + typeName: undefined, + writableTypeName: undefined, + contentType: 'application/octet-stream', + isSynthesized: false, + } } - return { typeName: undefined, writableTypeName: undefined, contentType: 'application/json', isSynthesized: false } + return { + typeName: undefined, + writableTypeName: undefined, + contentType: 'application/json', + isSynthesized: false, + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 213b233a..164cf111 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: '@stryker-mutator/vitest-runner': specifier: 'catalog:' version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.0.0))(vitest@4.1.9) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/node': specifier: 'catalog:' version: 26.0.0 @@ -331,15 +334,24 @@ importers: esbuild: specifier: 'catalog:' version: 0.28.1 + express: + specifier: ^5.2.1 + version: 5.2.1 fast-check: specifier: 'catalog:' version: 4.8.0 + hono: + specifier: ^4.12.26 + version: 4.12.26 typescript: specifier: 'catalog:' version: 6.0.3 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)) + zod: + specifier: 'catalog:' + version: 4.4.3 packages/openapi-zod-ts: dependencies: