-
-
Notifications
You must be signed in to change notification settings - Fork 1
fix(openapi-server): handle explode:true array query params in hono/express routers #407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,3 +11,4 @@ reports/ | |
| *.tgz | ||
| examples/generated-server/ | ||
| .astro/ | ||
| .typecheck-scratch/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
98 changes: 98 additions & 0 deletions
98
packages/openapi-server/src/__tests__/array-query-typecheck.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
171
packages/openapi-server/src/__tests__/hono-express-array-query.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
87
packages/openapi-server/src/__tests__/ts-compile-helpers.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ) | ||
|
|
||
| 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}`) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.convertCompilerOptionsFromJsonreturns{ options, errors }; onlyoptionsis 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
📝 Committable suggestion
🤖 Prompt for AI Agents