Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
278 changes: 277 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,281 @@ export default class Elysia<
return this.router.history
}

/**
* Get routes with guard() schemas merged into direct hook properties.
*
* This method flattens the `standaloneValidator` array (created by `guard()` calls)
* into direct hook properties (body, query, headers, params, cookie, response).
* This makes it easier for plugins to access the complete validation schema for each route,
* including schemas defined in parent guards.
*
* @example
* ```ts
* const app = new Elysia().guard(
* { headers: t.Object({ authorization: t.String() }) },
* (app) => app.get('/users', () => users, {
* query: t.Object({ page: t.Number() })
* })
* )
*
* // Without flattening:
* // route.hooks.standaloneValidator = [{ headers: ... }]
* // route.hooks.query = { page: ... }
*
* // With flattening:
* // route.hooks.headers = { authorization: ... }
* // route.hooks.query = { page: ... }
* ```
*
* @returns Routes with flattened schema structure where guard schemas are merged
* into direct properties. Routes without guards are returned unchanged.
*
* @remarks
* - Route-level schemas take precedence over guard schemas when merging
* - String schema references (from `.model()`) are preserved as TRef nodes
* - Response schemas properly handle both plain schemas and status code objects
* - This is a protected method intended for plugin authors who need schema introspection
*/
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
)
}
}

// Normalize any remaining string references in the final result
if (typeof merged.body === 'string') {
merged.body = this.normalizeSchemaReference(merged.body)
}
if (typeof merged.headers === 'string') {
merged.headers = this.normalizeSchemaReference(merged.headers)
}
if (typeof merged.query === 'string') {
merged.query = this.normalizeSchemaReference(merged.query)
}
if (typeof merged.params === 'string') {
merged.params = this.normalizeSchemaReference(merged.params)
}
if (typeof merged.cookie === 'string') {
merged.cookie = this.normalizeSchemaReference(merged.cookie)
}
if (merged.response && typeof merged.response !== 'string') {
// Normalize string references in status code objects
const response = merged.response as any
if ('type' in response || '$ref' in response) {
// It's a schema, not a status code object
if (typeof response === 'string') {
merged.response = this.normalizeSchemaReference(response)
}
} else {
// It's a status code object, normalize each value
for (const [status, schema] of Object.entries(response)) {
if (typeof schema === 'string') {
response[status] = this.normalizeSchemaReference(schema)
}
}
}
}

return merged
}

/**
* Normalize string schema references to TRef nodes for proper merging
*/
private normalizeSchemaReference(
schema: TSchema | string | undefined
): TSchema | undefined {
if (!schema) return undefined
if (typeof schema !== 'string') return schema

// Convert string reference to t.Ref node
// This allows string aliases to participate in schema composition
return t.Ref(schema)
}

/**
* Check if a value is a TypeBox schema (vs a status code object)
* Uses the TypeBox Kind symbol which all schemas have.
*
* This method distinguishes between:
* - TypeBox schemas: Have the Kind symbol (unions, intersects, objects, etc.)
* - Status code objects: Plain objects with numeric keys like { 200: schema, 404: schema }
*/
private isTSchema(value: any): value is TSchema {
if (!value || typeof value !== 'object') return false

// All TypeBox schemas have the Kind symbol
if (Kind in value) return true

// Additional check: if it's an object with only numeric keys, it's likely a status code map
const keys = Object.keys(value)
if (keys.length > 0 && keys.every(k => !isNaN(Number(k)))) {
return false
}

return false
}

/**
* 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

// Normalize string references to TRef nodes so they can be merged
const existingSchema = this.normalizeSchemaReference(existing)
const incomingSchema = this.normalizeSchemaReference(incoming)

if (!existingSchema) return incoming
if (!incomingSchema) return existing

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

// 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

// Normalize string references to TRef nodes
const normalizedExisting = typeof existing === 'string'
? this.normalizeSchemaReference(existing)
: existing
const normalizedIncoming = typeof incoming === 'string'
? this.normalizeSchemaReference(incoming)
: incoming

if (!normalizedExisting) return incoming
if (!normalizedIncoming) return existing

// Check if either is a TSchema (using Kind symbol) vs status code object
// This correctly handles all TypeBox schemas including unions, intersects, etc.
const existingIsSchema = this.isTSchema(normalizedExisting)
const incomingIsSchema = this.isTSchema(normalizedIncoming)

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

// 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 (normalizedExisting as Record<number, TSchema | string>)[200] ===
undefined
? {
...normalizedExisting,
200: normalizedIncoming
}
: normalizedExisting
}

// 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 {
...normalizedIncoming,
200: normalizedExisting
}
}

// Both are status code objects, merge them
return {
...normalizedIncoming,
...normalizedExisting
}
}

protected getGlobalDefinitions() {
return this.definitions
}
Expand Down
Loading
Loading