Skip to content

Commit 648aef2

Browse files
committed
🔧 fix: fix misclassification of non-object response schemas
1 parent a4c480d commit 648aef2

File tree

2 files changed

+169
-6
lines changed

2 files changed

+169
-6
lines changed

src/index.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -335,10 +335,39 @@ export default class Elysia<
335335
}
336336

337337
/**
338-
* Get routes with standaloneValidator schemas merged into direct hook properties.
339-
* This is useful for plugins that need to access guard() schemas.
338+
* Get routes with guard() schemas merged into direct hook properties.
340339
*
341-
* @returns Routes with flattened schema structure
340+
* This method flattens the `standaloneValidator` array (created by `guard()` calls)
341+
* into direct hook properties (body, query, headers, params, cookie, response).
342+
* This makes it easier for plugins to access the complete validation schema for each route,
343+
* including schemas defined in parent guards.
344+
*
345+
* @example
346+
* ```ts
347+
* const app = new Elysia().guard(
348+
* { headers: t.Object({ authorization: t.String() }) },
349+
* (app) => app.get('/users', () => users, {
350+
* query: t.Object({ page: t.Number() })
351+
* })
352+
* )
353+
*
354+
* // Without flattening:
355+
* // route.hooks.standaloneValidator = [{ headers: ... }]
356+
* // route.hooks.query = { page: ... }
357+
*
358+
* // With flattening:
359+
* // route.hooks.headers = { authorization: ... }
360+
* // route.hooks.query = { page: ... }
361+
* ```
362+
*
363+
* @returns Routes with flattened schema structure where guard schemas are merged
364+
* into direct properties. Routes without guards are returned unchanged.
365+
*
366+
* @remarks
367+
* - Route-level schemas take precedence over guard schemas when merging
368+
* - String schema references (from `.model()`) are preserved as TRef nodes
369+
* - Response schemas properly handle both plain schemas and status code objects
370+
* - This is a protected method intended for plugin authors who need schema introspection
342371
*/
343372
protected getFlattenedRoutes(): InternalRoute[] {
344373
return this.router.history.map((route) => {
@@ -452,6 +481,29 @@ export default class Elysia<
452481
return t.Ref(schema)
453482
}
454483

484+
/**
485+
* Check if a value is a TypeBox schema (vs a status code object)
486+
* Uses the TypeBox Kind symbol which all schemas have.
487+
*
488+
* This method distinguishes between:
489+
* - TypeBox schemas: Have the Kind symbol (unions, intersects, objects, etc.)
490+
* - Status code objects: Plain objects with numeric keys like { 200: schema, 404: schema }
491+
*/
492+
private isTSchema(value: any): value is TSchema {
493+
if (!value || typeof value !== 'object') return false
494+
495+
// All TypeBox schemas have the Kind symbol
496+
if (Kind in value) return true
497+
498+
// Additional check: if it's an object with only numeric keys, it's likely a status code map
499+
const keys = Object.keys(value)
500+
if (keys.length > 0 && keys.every(k => !isNaN(Number(k)))) {
501+
return false
502+
}
503+
504+
return false
505+
}
506+
455507
/**
456508
* Merge two schema properties (body, query, headers, params, cookie)
457509
*/
@@ -519,9 +571,10 @@ export default class Elysia<
519571
if (!normalizedExisting) return incoming
520572
if (!normalizedIncoming) return existing
521573

522-
// Check if either is a TSchema (has 'type' or '$ref' property) vs status code object
523-
const existingIsSchema = 'type' in normalizedExisting || '$ref' in normalizedExisting
524-
const incomingIsSchema = 'type' in normalizedIncoming || '$ref' in normalizedIncoming
574+
// Check if either is a TSchema (using Kind symbol) vs status code object
575+
// This correctly handles all TypeBox schemas including unions, intersects, etc.
576+
const existingIsSchema = this.isTSchema(normalizedExisting)
577+
const incomingIsSchema = this.isTSchema(normalizedIncoming)
525578

526579
// If both are plain schemas, preserve existing (route-specific schema takes precedence)
527580
if (existingIsSchema && incomingIsSchema) {

test/core/flattened-routes.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,114 @@ describe('getFlattenedRoutes', () => {
271271
expect(usersRoute?.hooks.response[401].$ref).toBe('ErrorResponse')
272272
expect(usersRoute?.hooks.response[500].$ref).toBe('ErrorResponse')
273273
})
274+
275+
it('correctly handles union response schemas from guards', () => {
276+
// Regression test: unions don't have 'type' property, only 'anyOf'
277+
// Previous implementation would misclassify them as status code objects
278+
const app = new Elysia().guard(
279+
{
280+
response: t.Union([t.String(), t.Number()])
281+
},
282+
(app) =>
283+
app.get('/data', () => 'test', {
284+
response: t.Object({
285+
value: t.String()
286+
})
287+
})
288+
)
289+
290+
// @ts-expect-error - accessing protected method for testing
291+
const flatRoutes = app.getFlattenedRoutes()
292+
293+
const dataRoute = flatRoutes.find((r) => r.path === '/data')
294+
295+
expect(dataRoute).toBeDefined()
296+
expect(dataRoute?.hooks.response).toBeDefined()
297+
298+
// The route-level object schema should be preserved (takes precedence)
299+
expect(dataRoute?.hooks.response.type).toBe('object')
300+
expect(dataRoute?.hooks.response.properties).toHaveProperty('value')
301+
302+
// Should NOT have anyOf from the union polluting the response
303+
expect(dataRoute?.hooks.response.anyOf).toBeUndefined()
304+
305+
// Should NOT have a synthetic status code structure
306+
expect(dataRoute?.hooks.response[200]).toBeUndefined()
307+
})
308+
309+
it('correctly handles intersect response schemas from guards', () => {
310+
// Intersects use 'allOf' instead of 'type'
311+
const app = new Elysia().guard(
312+
{
313+
response: {
314+
200: t.Intersect([
315+
t.Object({ id: t.String() }),
316+
t.Object({ timestamp: t.Number() })
317+
]),
318+
404: t.Object({ error: t.String() })
319+
}
320+
},
321+
(app) =>
322+
app.get('/item', () => ({ id: '1', timestamp: 123 }), {
323+
response: t.Object({
324+
id: t.String(),
325+
timestamp: t.Number(),
326+
extra: t.String()
327+
})
328+
})
329+
)
330+
331+
// @ts-expect-error - accessing protected method for testing
332+
const flatRoutes = app.getFlattenedRoutes()
333+
334+
const itemRoute = flatRoutes.find((r) => r.path === '/item')
335+
336+
expect(itemRoute).toBeDefined()
337+
expect(itemRoute?.hooks.response).toBeDefined()
338+
339+
// The 200 response should be the route-level object (takes precedence)
340+
expect(itemRoute?.hooks.response[200]).toBeDefined()
341+
expect(itemRoute?.hooks.response[200].type).toBe('object')
342+
expect(itemRoute?.hooks.response[200].properties).toHaveProperty('extra')
343+
344+
// The 404 from guard should be preserved
345+
expect(itemRoute?.hooks.response[404]).toBeDefined()
346+
expect(itemRoute?.hooks.response[404].type).toBe('object')
347+
expect(itemRoute?.hooks.response[404].properties).toHaveProperty('error')
348+
349+
// Should NOT have allOf polluting the status code object
350+
expect(itemRoute?.hooks.response.allOf).toBeUndefined()
351+
})
352+
353+
it('correctly handles t.Any and other schemas without type property', () => {
354+
// Test other schemas that don't have 'type' property
355+
const app = new Elysia().guard(
356+
{
357+
response: {
358+
200: t.Any(),
359+
500: t.Object({ error: t.String() })
360+
}
361+
},
362+
(app) =>
363+
app.get('/any', () => 'anything', {
364+
response: t.String()
365+
})
366+
)
367+
368+
// @ts-expect-error - accessing protected method for testing
369+
const flatRoutes = app.getFlattenedRoutes()
370+
371+
const anyRoute = flatRoutes.find((r) => r.path === '/any')
372+
373+
expect(anyRoute).toBeDefined()
374+
expect(anyRoute?.hooks.response).toBeDefined()
375+
376+
// The 200 response should be the route-level string schema
377+
expect(anyRoute?.hooks.response[200]).toBeDefined()
378+
expect(anyRoute?.hooks.response[200].type).toBe('string')
379+
380+
// The 500 from guard should be preserved
381+
expect(anyRoute?.hooks.response[500]).toBeDefined()
382+
expect(anyRoute?.hooks.response[500].type).toBe('object')
383+
})
274384
})

0 commit comments

Comments
 (0)