Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
169 changes: 168 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ import {
getResponseSchemaValidator,
getCookieValidator,
ElysiaTypeCheck,
queryCoercions
queryCoercions,
mergeObjectSchemas
} from './schema'
import {
composeHandler,
Expand Down Expand Up @@ -333,6 +334,172 @@ export default class Elysia<
return this.router.history
}

/**
* Get routes with standaloneValidator schemas merged into direct hook properties.
* This is useful for plugins that need to access guard() schemas.
*
* @returns Routes with flattened schema structure
*/
protected getFlattenedRoutes(): InternalRoute[] {
return this.router.history.map((route) => {
if (!route.hooks?.standaloneValidator?.length) {
return route
}

return {
...route,
hooks: this.mergeStandaloneValidators(route.hooks)
}
})
}

/**
* Merge standaloneValidator array into direct hook properties
*/
private mergeStandaloneValidators(hooks: AnyLocalHook): AnyLocalHook {
const merged = { ...hooks }

if (!hooks.standaloneValidator?.length) return merged

for (const validator of hooks.standaloneValidator) {
// Merge each schema property
if (validator.body) {
merged.body = this.mergeSchemaProperty(
merged.body,
validator.body
)
}
if (validator.headers) {
merged.headers = this.mergeSchemaProperty(
merged.headers,
validator.headers
)
}
if (validator.query) {
merged.query = this.mergeSchemaProperty(
merged.query,
validator.query
)
}
if (validator.params) {
merged.params = this.mergeSchemaProperty(
merged.params,
validator.params
)
}
if (validator.cookie) {
merged.cookie = this.mergeSchemaProperty(
merged.cookie,
validator.cookie
)
}
if (validator.response) {
merged.response = this.mergeResponseSchema(
merged.response,
validator.response
)
}
}

return merged
}

/**
* Merge two schema properties (body, query, headers, params, cookie)
*/
private mergeSchemaProperty(
existing: TSchema | string | undefined,
incoming: TSchema | string | undefined
): TSchema | string | undefined {
if (!existing) return incoming
if (!incoming) return existing

// If either is a string reference, we can't merge - use incoming
if (typeof existing === 'string' || typeof incoming === 'string') {
return incoming
}

// If both are object schemas, merge them
const { schema: mergedSchema, notObjects } = mergeObjectSchemas([
existing,
incoming
])

// If we have non-object schemas, create an Intersect
if (notObjects.length > 0) {
if (mergedSchema) {
return t.Intersect([mergedSchema, ...notObjects])
}
return notObjects.length === 1
? notObjects[0]
: t.Intersect(notObjects)
}

return mergedSchema
}

/**
* Merge response schemas (handles status code objects)
*/
private mergeResponseSchema(
existing:
| TSchema
| { [status: number]: TSchema }
| string
| { [status: number]: string | TSchema }
| undefined,
incoming:
| TSchema
| { [status: number]: TSchema }
| string
| { [status: number]: string | TSchema }
| undefined
): TSchema | { [status: number]: TSchema | string } | string | undefined {
if (!existing) return incoming
if (!incoming) return existing

// If either is a string, we can't merge - use incoming
if (typeof existing === 'string' || typeof incoming === 'string') {
return incoming
}

// Check if either is a TSchema (has 'type' property) vs status code object
const existingIsSchema = 'type' in existing
const incomingIsSchema = 'type' in incoming

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

// If existing is status code object and incoming is plain schema,
// merge incoming as status 200 to preserve other status codes
if (!existingIsSchema && incomingIsSchema) {
return (existing as Record<number, TSchema | string>)[200] ===
undefined
? {
...existing,
200: incoming
}
: existing
}

// If existing is plain schema and incoming is status code object,
// merge existing as status 200 into incoming (spread incoming first to preserve all status codes)
if (existingIsSchema && !incomingIsSchema) {
return {
...incoming,
200: existing
}
}

// Both are status code objects, merge them
return {
...incoming,
...existing
}
}

protected getGlobalDefinitions() {
return this.definitions
}
Expand Down
208 changes: 208 additions & 0 deletions test/core/flattened-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { Elysia, t } from '../../src'

import { describe, expect, it } from 'bun:test'

describe('getFlattenedRoutes', () => {
it('merges guard standaloneValidator into direct properties', () => {
const app = new Elysia().guard(
{
body: t.Object({
username: t.String(),
password: t.String()
})
},
(app) =>
app
.post('/sign-up', ({ body }) => body)
.post('/sign-in', ({ body }) => body)
)

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()

const signUpRoute = flatRoutes.find((r) => r.path === '/sign-up')
const signInRoute = flatRoutes.find((r) => r.path === '/sign-in')

expect(signUpRoute).toBeDefined()
expect(signInRoute).toBeDefined()

// Check that body schema exists in hooks
expect(signUpRoute?.hooks.body).toBeDefined()
expect(signInRoute?.hooks.body).toBeDefined()

// Verify it's an object schema with the expected properties
expect(signUpRoute?.hooks.body.type).toBe('object')
expect(signUpRoute?.hooks.body.properties).toHaveProperty('username')
expect(signUpRoute?.hooks.body.properties).toHaveProperty('password')
})

it('returns original route when no standaloneValidator', () => {
const app = new Elysia().get('/', () => 'hi')

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()
// @ts-expect-error - accessing protected method for testing
const normalRoutes = app.getGlobalRoutes()

expect(flatRoutes.length).toBe(normalRoutes.length)
expect(flatRoutes[0]).toBe(normalRoutes[0])
})

it('merges nested guard schemas', () => {
const app = new Elysia().guard(
{
headers: t.Object({
authorization: t.String()
})
},
(app) =>
app.guard(
{
body: t.Object({
data: t.String()
})
},
(app) => app.post('/nested', ({ body }) => body)
)
)

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()

const nestedRoute = flatRoutes.find((r) => r.path === '/nested')

expect(nestedRoute).toBeDefined()
expect(nestedRoute?.hooks.headers).toBeDefined()
expect(nestedRoute?.hooks.body).toBeDefined()
})

it('merges guard schema with direct route schema', () => {
const app = new Elysia().guard(
{
headers: t.Object({
'x-api-key': t.String()
})
},
(app) =>
app.post('/mixed', ({ body }) => body, {
body: t.Object({
name: t.String()
})
})
)

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()

const mixedRoute = flatRoutes.find((r) => r.path === '/mixed')

expect(mixedRoute).toBeDefined()
expect(mixedRoute?.hooks.headers).toBeDefined()
expect(mixedRoute?.hooks.body).toBeDefined()

// Both guard and direct schemas should be present
expect(mixedRoute?.hooks.headers.type).toBe('object')
expect(mixedRoute?.hooks.body.type).toBe('object')
})

it('handles query and params schemas from guard', () => {
const app = new Elysia().guard(
{
query: t.Object({
page: t.String()
}),
params: t.Object({
id: t.String()
})
},
(app) => app.get('/items/:id', ({ params, query }) => ({ params, query }))
)

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()

const itemRoute = flatRoutes.find((r) => r.path === '/items/:id')

expect(itemRoute).toBeDefined()
expect(itemRoute?.hooks.query).toBeDefined()
expect(itemRoute?.hooks.params).toBeDefined()
})

it('handles cookie schemas from guard', () => {
const app = new Elysia().guard(
{
cookie: t.Object({
session: t.String()
})
},
(app) => app.get('/profile', ({ cookie }) => cookie)
)

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()

const profileRoute = flatRoutes.find((r) => r.path === '/profile')

expect(profileRoute).toBeDefined()
expect(profileRoute?.hooks.cookie).toBeDefined()
})

it('handles response schemas from guard', () => {
const app = new Elysia().guard(
{
response: {
200: t.Object({
success: t.Boolean()
})
}
},
(app) => app.get('/status', () => ({ success: true }))
)

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()

const statusRoute = flatRoutes.find((r) => r.path === '/status')

expect(statusRoute).toBeDefined()
expect(statusRoute?.hooks.response).toBeDefined()
expect(statusRoute?.hooks.response[200]).toBeDefined()
})

it('merges status-code map response with plain schema without data loss', () => {
// Test case for the coderabbitai feedback - ensure we don't lose status-code schemas
const app = new Elysia().guard(
{
response: {
200: t.Object({ data: t.String() }),
404: t.Object({ error: t.String() }),
500: t.Object({ message: t.String() })
}
},
(app) =>
app.get('/data', () => ({ data: 'test' }), {
response: t.String() // Plain schema should be merged as 200, not replace entire map
})
)

// @ts-expect-error - accessing protected method for testing
const flatRoutes = app.getFlattenedRoutes()

const dataRoute = flatRoutes.find((r) => r.path === '/data')

expect(dataRoute).toBeDefined()
expect(dataRoute?.hooks.response).toBeDefined()

// The plain schema should override 200 but preserve 404 and 500
expect(dataRoute?.hooks.response[200]).toBeDefined()
expect(dataRoute?.hooks.response[404]).toBeDefined()
expect(dataRoute?.hooks.response[500]).toBeDefined()

// The 200 response should be the plain schema from the route (more specific)
expect(dataRoute?.hooks.response[200].type).toBe('string')

// Other status codes should be preserved from guard
expect(dataRoute?.hooks.response[404].type).toBe('object')
expect(dataRoute?.hooks.response[500].type).toBe('object')
})
})
Loading