diff --git a/src/index.ts b/src/index.ts index ed76f273..1a54f4f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,7 +49,8 @@ import { getResponseSchemaValidator, getCookieValidator, ElysiaTypeCheck, - queryCoercions + queryCoercions, + mergeObjectSchemas } from './schema' import { composeHandler, @@ -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)[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 } diff --git a/test/core/flattened-routes.test.ts b/test/core/flattened-routes.test.ts new file mode 100644 index 00000000..0077f4f2 --- /dev/null +++ b/test/core/flattened-routes.test.ts @@ -0,0 +1,442 @@ +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('preserves standaloneValidator array when flattening', () => { + const app = new Elysia().guard( + { + headers: t.Object({ + authorization: t.String() + }), + body: t.Object({ + data: t.String() + }), + response: { + 401: t.Object({ error: t.String() }), + 500: t.Object({ message: t.String() }) + } + }, + (app) => + app.post('/protected', ({ body }) => body, { + response: t.Object({ + success: t.Boolean(), + data: t.String() + }) + }) + ) + + // @ts-expect-error - accessing protected method for testing + const flatRoutes = app.getFlattenedRoutes() + + const protectedRoute = flatRoutes.find((r) => r.path === '/protected') + + expect(protectedRoute).toBeDefined() + + // The standaloneValidator array should still exist after flattening + expect(protectedRoute?.hooks.standaloneValidator).toBeDefined() + expect(Array.isArray(protectedRoute?.hooks.standaloneValidator)).toBe( + true + ) + + // Should have at least one validator from the guard + expect(protectedRoute?.hooks.standaloneValidator?.length).toBeGreaterThan( + 0 + ) + + // Verify the standaloneValidator contains the guard schemas + const validator = protectedRoute?.hooks.standaloneValidator?.[0] + expect(validator).toBeDefined() + expect(validator?.headers).toBeDefined() + expect(validator?.body).toBeDefined() + expect(validator?.response).toBeDefined() + + // Verify schemas were also flattened into direct properties + expect(protectedRoute?.hooks.headers).toBeDefined() + expect(protectedRoute?.hooks.headers.type).toBe('object') + expect(protectedRoute?.hooks.body).toBeDefined() + expect(protectedRoute?.hooks.body.type).toBe('object') + expect(protectedRoute?.hooks.response).toBeDefined() + expect(protectedRoute?.hooks.response[401]).toBeDefined() + expect(protectedRoute?.hooks.response[500]).toBeDefined() + }) + + 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') + }) + + it('preserves string schema aliases during merging', () => { + // Define models that will be referenced by string aliases + const app = new Elysia() + .model({ + UserPayload: t.Object({ + name: t.String(), + email: t.String() + }), + UserResponse: t.Object({ + id: t.String(), + name: t.String() + }), + ErrorResponse: t.Object({ + error: t.String(), + message: t.String() + }) + }) + .guard( + { + headers: t.Object({ + authorization: t.String() + }), + response: { + 401: 'ErrorResponse', + 500: 'ErrorResponse' + } + }, + (app) => + app.post('/users', ({ body }) => body, { + body: 'UserPayload', + response: 'UserResponse' + }) + ) + + // @ts-expect-error - accessing protected method for testing + const flatRoutes = app.getFlattenedRoutes() + + const usersRoute = flatRoutes.find((r) => r.path === '/users') + + expect(usersRoute).toBeDefined() + + // Body should be a TRef to UserPayload + expect(usersRoute?.hooks.body).toBeDefined() + expect(usersRoute?.hooks.body.$ref).toBe('UserPayload') + + // Headers should be merged from guard + expect(usersRoute?.hooks.headers).toBeDefined() + expect(usersRoute?.hooks.headers.type).toBe('object') + expect(usersRoute?.hooks.headers.properties).toHaveProperty( + 'authorization' + ) + + // Response should preserve both route-level (200) and guard-level (401, 500) schemas + expect(usersRoute?.hooks.response).toBeDefined() + expect(usersRoute?.hooks.response[200]).toBeDefined() + expect(usersRoute?.hooks.response[401]).toBeDefined() + expect(usersRoute?.hooks.response[500]).toBeDefined() + + // The 200 response should be the TRef from the route + expect(usersRoute?.hooks.response[200].$ref).toBe('UserResponse') + + // The error responses should be TRefs from the guard + expect(usersRoute?.hooks.response[401].$ref).toBe('ErrorResponse') + expect(usersRoute?.hooks.response[500].$ref).toBe('ErrorResponse') + }) + + it('correctly handles union response schemas from guards', () => { + // Regression test: unions don't have 'type' property, only 'anyOf' + // Previous implementation would misclassify them as status code objects + const app = new Elysia().guard( + { + response: t.Union([t.String(), t.Number()]) + }, + (app) => + app.get('/data', () => 'test', { + response: t.Object({ + value: t.String() + }) + }) + ) + + // @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 route-level object schema should be preserved (takes precedence) + expect(dataRoute?.hooks.response.type).toBe('object') + expect(dataRoute?.hooks.response.properties).toHaveProperty('value') + + // Should NOT have anyOf from the union polluting the response + expect(dataRoute?.hooks.response.anyOf).toBeUndefined() + + // Should NOT have a synthetic status code structure + expect(dataRoute?.hooks.response[200]).toBeUndefined() + }) + + it('correctly handles intersect response schemas from guards', () => { + // Intersects use 'allOf' instead of 'type' + const app = new Elysia().guard( + { + response: { + 200: t.Intersect([ + t.Object({ id: t.String() }), + t.Object({ timestamp: t.Number() }) + ]), + 404: t.Object({ error: t.String() }) + } + }, + (app) => + app.get('/item', () => ({ id: '1', timestamp: 123 }), { + response: t.Object({ + id: t.String(), + timestamp: t.Number(), + extra: t.String() + }) + }) + ) + + // @ts-expect-error - accessing protected method for testing + const flatRoutes = app.getFlattenedRoutes() + + const itemRoute = flatRoutes.find((r) => r.path === '/item') + + expect(itemRoute).toBeDefined() + expect(itemRoute?.hooks.response).toBeDefined() + + // The 200 response should be the route-level object (takes precedence) + expect(itemRoute?.hooks.response[200]).toBeDefined() + expect(itemRoute?.hooks.response[200].type).toBe('object') + expect(itemRoute?.hooks.response[200].properties).toHaveProperty('extra') + + // The 404 from guard should be preserved + expect(itemRoute?.hooks.response[404]).toBeDefined() + expect(itemRoute?.hooks.response[404].type).toBe('object') + expect(itemRoute?.hooks.response[404].properties).toHaveProperty('error') + + // Should NOT have allOf polluting the status code object + expect(itemRoute?.hooks.response.allOf).toBeUndefined() + }) + + it('correctly handles t.Any and other schemas without type property', () => { + // Test other schemas that don't have 'type' property + const app = new Elysia().guard( + { + response: { + 200: t.Any(), + 500: t.Object({ error: t.String() }) + } + }, + (app) => + app.get('/any', () => 'anything', { + response: t.String() + }) + ) + + // @ts-expect-error - accessing protected method for testing + const flatRoutes = app.getFlattenedRoutes() + + const anyRoute = flatRoutes.find((r) => r.path === '/any') + + expect(anyRoute).toBeDefined() + expect(anyRoute?.hooks.response).toBeDefined() + + // The 200 response should be the route-level string schema + expect(anyRoute?.hooks.response[200]).toBeDefined() + expect(anyRoute?.hooks.response[200].type).toBe('string') + + // The 500 from guard should be preserved + expect(anyRoute?.hooks.response[500]).toBeDefined() + expect(anyRoute?.hooks.response[500].type).toBe('object') + }) +})