Skip to content

Commit a4c480d

Browse files
committed
🔧 fix: preserve route-level string schemas when flattening
1 parent 651470e commit a4c480d

File tree

2 files changed

+144
-22
lines changed

2 files changed

+144
-22
lines changed

src/index.ts

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,57 @@ export default class Elysia<
401401
}
402402
}
403403

404+
// Normalize any remaining string references in the final result
405+
if (typeof merged.body === 'string') {
406+
merged.body = this.normalizeSchemaReference(merged.body)
407+
}
408+
if (typeof merged.headers === 'string') {
409+
merged.headers = this.normalizeSchemaReference(merged.headers)
410+
}
411+
if (typeof merged.query === 'string') {
412+
merged.query = this.normalizeSchemaReference(merged.query)
413+
}
414+
if (typeof merged.params === 'string') {
415+
merged.params = this.normalizeSchemaReference(merged.params)
416+
}
417+
if (typeof merged.cookie === 'string') {
418+
merged.cookie = this.normalizeSchemaReference(merged.cookie)
419+
}
420+
if (merged.response && typeof merged.response !== 'string') {
421+
// Normalize string references in status code objects
422+
const response = merged.response as any
423+
if ('type' in response || '$ref' in response) {
424+
// It's a schema, not a status code object
425+
if (typeof response === 'string') {
426+
merged.response = this.normalizeSchemaReference(response)
427+
}
428+
} else {
429+
// It's a status code object, normalize each value
430+
for (const [status, schema] of Object.entries(response)) {
431+
if (typeof schema === 'string') {
432+
response[status] = this.normalizeSchemaReference(schema)
433+
}
434+
}
435+
}
436+
}
437+
404438
return merged
405439
}
406440

441+
/**
442+
* Normalize string schema references to TRef nodes for proper merging
443+
*/
444+
private normalizeSchemaReference(
445+
schema: TSchema | string | undefined
446+
): TSchema | undefined {
447+
if (!schema) return undefined
448+
if (typeof schema !== 'string') return schema
449+
450+
// Convert string reference to t.Ref node
451+
// This allows string aliases to participate in schema composition
452+
return t.Ref(schema)
453+
}
454+
407455
/**
408456
* Merge two schema properties (body, query, headers, params, cookie)
409457
*/
@@ -414,15 +462,17 @@ export default class Elysia<
414462
if (!existing) return incoming
415463
if (!incoming) return existing
416464

417-
// If either is a string reference, we can't merge - use incoming
418-
if (typeof existing === 'string' || typeof incoming === 'string') {
419-
return incoming
420-
}
465+
// Normalize string references to TRef nodes so they can be merged
466+
const existingSchema = this.normalizeSchemaReference(existing)
467+
const incomingSchema = this.normalizeSchemaReference(incoming)
468+
469+
if (!existingSchema) return incoming
470+
if (!incomingSchema) return existing
421471

422472
// If both are object schemas, merge them
423473
const { schema: mergedSchema, notObjects } = mergeObjectSchemas([
424-
existing,
425-
incoming
474+
existingSchema,
475+
incomingSchema
426476
])
427477

428478
// If we have non-object schemas, create an Intersect
@@ -458,45 +508,51 @@ export default class Elysia<
458508
if (!existing) return incoming
459509
if (!incoming) return existing
460510

461-
// If either is a string, we can't merge - use incoming
462-
if (typeof existing === 'string' || typeof incoming === 'string') {
463-
return incoming
464-
}
511+
// Normalize string references to TRef nodes
512+
const normalizedExisting = typeof existing === 'string'
513+
? this.normalizeSchemaReference(existing)
514+
: existing
515+
const normalizedIncoming = typeof incoming === 'string'
516+
? this.normalizeSchemaReference(incoming)
517+
: incoming
518+
519+
if (!normalizedExisting) return incoming
520+
if (!normalizedIncoming) return existing
465521

466-
// Check if either is a TSchema (has 'type' property) vs status code object
467-
const existingIsSchema = 'type' in existing
468-
const incomingIsSchema = 'type' in incoming
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
469525

470526
// If both are plain schemas, preserve existing (route-specific schema takes precedence)
471527
if (existingIsSchema && incomingIsSchema) {
472-
return existing
528+
return normalizedExisting
473529
}
474530

475531
// If existing is status code object and incoming is plain schema,
476532
// merge incoming as status 200 to preserve other status codes
477533
if (!existingIsSchema && incomingIsSchema) {
478-
return (existing as Record<number, TSchema | string>)[200] ===
534+
return (normalizedExisting as Record<number, TSchema | string>)[200] ===
479535
undefined
480536
? {
481-
...existing,
482-
200: incoming
537+
...normalizedExisting,
538+
200: normalizedIncoming
483539
}
484-
: existing
540+
: normalizedExisting
485541
}
486542

487543
// If existing is plain schema and incoming is status code object,
488544
// merge existing as status 200 into incoming (spread incoming first to preserve all status codes)
489545
if (existingIsSchema && !incomingIsSchema) {
490546
return {
491-
...incoming,
492-
200: existing
547+
...normalizedIncoming,
548+
200: normalizedExisting
493549
}
494550
}
495551

496552
// Both are status code objects, merge them
497553
return {
498-
...incoming,
499-
...existing
554+
...normalizedIncoming,
555+
...normalizedExisting
500556
}
501557
}
502558

test/core/flattened-routes.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,70 @@ describe('getFlattenedRoutes', () => {
205205
expect(dataRoute?.hooks.response[404].type).toBe('object')
206206
expect(dataRoute?.hooks.response[500].type).toBe('object')
207207
})
208+
209+
it('preserves string schema aliases during merging', () => {
210+
// Define models that will be referenced by string aliases
211+
const app = new Elysia()
212+
.model({
213+
UserPayload: t.Object({
214+
name: t.String(),
215+
email: t.String()
216+
}),
217+
UserResponse: t.Object({
218+
id: t.String(),
219+
name: t.String()
220+
}),
221+
ErrorResponse: t.Object({
222+
error: t.String(),
223+
message: t.String()
224+
})
225+
})
226+
.guard(
227+
{
228+
headers: t.Object({
229+
authorization: t.String()
230+
}),
231+
response: {
232+
401: 'ErrorResponse',
233+
500: 'ErrorResponse'
234+
}
235+
},
236+
(app) =>
237+
app.post('/users', ({ body }) => body, {
238+
body: 'UserPayload',
239+
response: 'UserResponse'
240+
})
241+
)
242+
243+
// @ts-expect-error - accessing protected method for testing
244+
const flatRoutes = app.getFlattenedRoutes()
245+
246+
const usersRoute = flatRoutes.find((r) => r.path === '/users')
247+
248+
expect(usersRoute).toBeDefined()
249+
250+
// Body should be a TRef to UserPayload
251+
expect(usersRoute?.hooks.body).toBeDefined()
252+
expect(usersRoute?.hooks.body.$ref).toBe('UserPayload')
253+
254+
// Headers should be merged from guard
255+
expect(usersRoute?.hooks.headers).toBeDefined()
256+
expect(usersRoute?.hooks.headers.type).toBe('object')
257+
expect(usersRoute?.hooks.headers.properties).toHaveProperty(
258+
'authorization'
259+
)
260+
261+
// Response should preserve both route-level (200) and guard-level (401, 500) schemas
262+
expect(usersRoute?.hooks.response).toBeDefined()
263+
expect(usersRoute?.hooks.response[200]).toBeDefined()
264+
expect(usersRoute?.hooks.response[401]).toBeDefined()
265+
expect(usersRoute?.hooks.response[500]).toBeDefined()
266+
267+
// The 200 response should be the TRef from the route
268+
expect(usersRoute?.hooks.response[200].$ref).toBe('UserResponse')
269+
270+
// The error responses should be TRefs from the guard
271+
expect(usersRoute?.hooks.response[401].$ref).toBe('ErrorResponse')
272+
expect(usersRoute?.hooks.response[500].$ref).toBe('ErrorResponse')
273+
})
208274
})

0 commit comments

Comments
 (0)