@@ -2,7 +2,7 @@ import { t, type AnyElysia, type TSchema, type InputSchema } from 'elysia'
22import type { HookContainer , StandardSchemaV1Like } from 'elysia/types'
33
44import type { OpenAPIV3 } from 'openapi-types'
5- import { Kind , TAnySchema , type TProperties } from '@sinclair/typebox'
5+ import { Kind , TAnySchema , type TProperties , type TObject } from '@sinclair/typebox'
66
77import type {
88 AdditionalReference ,
@@ -106,6 +106,335 @@ openapi({
106106
107107const warned = { } as Record < keyof typeof warnings , boolean | undefined >
108108
109+ // ============================================================================
110+ // Schema Flattening Helpers
111+ // ============================================================================
112+
113+ /**
114+ * Merge object schemas together
115+ * Returns merged object schema and any non-object schemas that couldn't be merged
116+ */
117+ const mergeObjectSchemas = (
118+ schemas : TSchema [ ]
119+ ) : {
120+ schema : TObject | undefined
121+ notObjects : TSchema [ ]
122+ } => {
123+ if ( schemas . length === 0 ) {
124+ return {
125+ schema : undefined ,
126+ notObjects : [ ]
127+ }
128+ }
129+ if ( schemas . length === 1 )
130+ return schemas [ 0 ] . type === 'object'
131+ ? {
132+ schema : schemas [ 0 ] as TObject ,
133+ notObjects : [ ]
134+ }
135+ : {
136+ schema : undefined ,
137+ notObjects : schemas
138+ }
139+
140+ let newSchema : TObject
141+ const notObjects = < TSchema [ ] > [ ]
142+
143+ let additionalPropertiesIsTrue = false
144+ let additionalPropertiesIsFalse = false
145+
146+ for ( const schema of schemas ) {
147+ if ( schema . type !== 'object' ) {
148+ notObjects . push ( schema )
149+ continue
150+ }
151+
152+ if ( 'additionalProperties' in schema ) {
153+ if ( schema . additionalProperties === true )
154+ additionalPropertiesIsTrue = true
155+ else if ( schema . additionalProperties === false )
156+ additionalPropertiesIsFalse = true
157+ }
158+
159+ if ( ! newSchema ! ) {
160+ newSchema = schema as TObject
161+ continue
162+ }
163+
164+ newSchema = {
165+ ...newSchema ,
166+ ...schema ,
167+ properties : {
168+ ...newSchema . properties ,
169+ ...schema . properties
170+ } ,
171+ required : [ ...( newSchema ?. required ?? [ ] ) , ...( schema . required ?? [ ] ) ]
172+ } as TObject
173+ }
174+
175+ if ( newSchema ! ) {
176+ if ( newSchema . required )
177+ newSchema . required = [ ...new Set ( newSchema . required ) ]
178+
179+ if ( additionalPropertiesIsFalse ) newSchema . additionalProperties = false
180+ else if ( additionalPropertiesIsTrue )
181+ newSchema . additionalProperties = true
182+ }
183+
184+ return {
185+ schema : newSchema ! ,
186+ notObjects
187+ }
188+ }
189+
190+ /**
191+ * Check if a value is a TypeBox schema (vs a status code object)
192+ * Uses the TypeBox Kind symbol which all schemas have.
193+ *
194+ * This method distinguishes between:
195+ * - TypeBox schemas: Have the Kind symbol (unions, intersects, objects, etc.)
196+ * - Status code objects: Plain objects with numeric keys like { 200: schema, 404: schema }
197+ */
198+ const isTSchema = ( value : any ) : value is TSchema => {
199+ if ( ! value || typeof value !== 'object' ) return false
200+
201+ // All TypeBox schemas have the Kind symbol
202+ if ( Kind in value ) return true
203+
204+ // Additional check: if it's an object with only numeric keys, it's likely a status code map
205+ const keys = Object . keys ( value )
206+ if ( keys . length > 0 && keys . every ( k => ! isNaN ( Number ( k ) ) ) ) {
207+ return false
208+ }
209+
210+ return false
211+ }
212+
213+ /**
214+ * Normalize string schema references to TRef nodes for proper merging
215+ */
216+ const normalizeSchemaReference = (
217+ schema : TSchema | string | undefined
218+ ) : TSchema | undefined => {
219+ if ( ! schema ) return undefined
220+ if ( typeof schema !== 'string' ) return schema
221+
222+ // Convert string reference to t.Ref node
223+ // This allows string aliases to participate in schema composition
224+ return t . Ref ( schema )
225+ }
226+
227+ /**
228+ * Merge two schema properties (body, query, headers, params, cookie)
229+ */
230+ const mergeSchemaProperty = (
231+ existing : TSchema | string | undefined ,
232+ incoming : TSchema | string | undefined
233+ ) : TSchema | string | undefined => {
234+ if ( ! existing ) return incoming
235+ if ( ! incoming ) return existing
236+
237+ // Normalize string references to TRef nodes so they can be merged
238+ const existingSchema = normalizeSchemaReference ( existing )
239+ const incomingSchema = normalizeSchemaReference ( incoming )
240+
241+ if ( ! existingSchema ) return incoming
242+ if ( ! incomingSchema ) return existing
243+
244+ // If both are object schemas, merge them
245+ const { schema : mergedSchema , notObjects } = mergeObjectSchemas ( [
246+ existingSchema ,
247+ incomingSchema
248+ ] )
249+
250+ // If we have non-object schemas, create an Intersect
251+ if ( notObjects . length > 0 ) {
252+ if ( mergedSchema ) {
253+ return t . Intersect ( [ mergedSchema , ...notObjects ] )
254+ }
255+ return notObjects . length === 1
256+ ? notObjects [ 0 ]
257+ : t . Intersect ( notObjects )
258+ }
259+
260+ return mergedSchema
261+ }
262+
263+ /**
264+ * Merge response schemas (handles status code objects)
265+ */
266+ const mergeResponseSchema = (
267+ existing :
268+ | TSchema
269+ | { [ status : number ] : TSchema }
270+ | string
271+ | { [ status : number ] : string | TSchema }
272+ | undefined ,
273+ incoming :
274+ | TSchema
275+ | { [ status : number ] : TSchema }
276+ | string
277+ | { [ status : number ] : string | TSchema }
278+ | undefined
279+ ) : TSchema | { [ status : number ] : TSchema | string } | string | undefined => {
280+ if ( ! existing ) return incoming
281+ if ( ! incoming ) return existing
282+
283+ // Normalize string references to TRef nodes
284+ const normalizedExisting = typeof existing === 'string'
285+ ? normalizeSchemaReference ( existing )
286+ : existing
287+ const normalizedIncoming = typeof incoming === 'string'
288+ ? normalizeSchemaReference ( incoming )
289+ : incoming
290+
291+ if ( ! normalizedExisting ) return incoming
292+ if ( ! normalizedIncoming ) return existing
293+
294+ // Check if either is a TSchema (using Kind symbol) vs status code object
295+ // This correctly handles all TypeBox schemas including unions, intersects, etc.
296+ const existingIsSchema = isTSchema ( normalizedExisting )
297+ const incomingIsSchema = isTSchema ( normalizedIncoming )
298+
299+ // If both are plain schemas, preserve existing (route-specific schema takes precedence)
300+ if ( existingIsSchema && incomingIsSchema ) {
301+ return normalizedExisting
302+ }
303+
304+ // If existing is status code object and incoming is plain schema,
305+ // merge incoming as status 200 to preserve other status codes
306+ if ( ! existingIsSchema && incomingIsSchema ) {
307+ return ( normalizedExisting as Record < number , TSchema | string > ) [ 200 ] ===
308+ undefined
309+ ? {
310+ ...normalizedExisting ,
311+ 200 : normalizedIncoming
312+ }
313+ : normalizedExisting
314+ }
315+
316+ // If existing is plain schema and incoming is status code object,
317+ // merge existing as status 200 into incoming (spread incoming first to preserve all status codes)
318+ if ( existingIsSchema && ! incomingIsSchema ) {
319+ return {
320+ ...normalizedIncoming ,
321+ 200 : normalizedExisting
322+ }
323+ }
324+
325+ // Both are status code objects, merge them
326+ return {
327+ ...normalizedIncoming ,
328+ ...normalizedExisting
329+ }
330+ }
331+
332+ /**
333+ * Merge standaloneValidator array into direct hook properties
334+ */
335+ const mergeStandaloneValidators = ( hooks : HookContainer ) : HookContainer => {
336+ const merged = { ...hooks }
337+
338+ if ( ! hooks . standaloneValidator ?. length ) return merged
339+
340+ for ( const validator of hooks . standaloneValidator ) {
341+ // Merge each schema property
342+ if ( validator . body ) {
343+ merged . body = mergeSchemaProperty (
344+ merged . body ,
345+ validator . body
346+ )
347+ }
348+ if ( validator . headers ) {
349+ merged . headers = mergeSchemaProperty (
350+ merged . headers ,
351+ validator . headers
352+ )
353+ }
354+ if ( validator . query ) {
355+ merged . query = mergeSchemaProperty (
356+ merged . query ,
357+ validator . query
358+ )
359+ }
360+ if ( validator . params ) {
361+ merged . params = mergeSchemaProperty (
362+ merged . params ,
363+ validator . params
364+ )
365+ }
366+ if ( validator . cookie ) {
367+ merged . cookie = mergeSchemaProperty (
368+ merged . cookie ,
369+ validator . cookie
370+ )
371+ }
372+ if ( validator . response ) {
373+ merged . response = mergeResponseSchema (
374+ merged . response ,
375+ validator . response
376+ )
377+ }
378+ }
379+
380+ // Normalize any remaining string references in the final result
381+ if ( typeof merged . body === 'string' ) {
382+ merged . body = normalizeSchemaReference ( merged . body )
383+ }
384+ if ( typeof merged . headers === 'string' ) {
385+ merged . headers = normalizeSchemaReference ( merged . headers )
386+ }
387+ if ( typeof merged . query === 'string' ) {
388+ merged . query = normalizeSchemaReference ( merged . query )
389+ }
390+ if ( typeof merged . params === 'string' ) {
391+ merged . params = normalizeSchemaReference ( merged . params )
392+ }
393+ if ( typeof merged . cookie === 'string' ) {
394+ merged . cookie = normalizeSchemaReference ( merged . cookie )
395+ }
396+ if ( merged . response && typeof merged . response !== 'string' ) {
397+ // Normalize string references in status code objects
398+ const response = merged . response as any
399+ if ( 'type' in response || '$ref' in response ) {
400+ // It's a schema, not a status code object
401+ if ( typeof response === 'string' ) {
402+ merged . response = normalizeSchemaReference ( response )
403+ }
404+ } else {
405+ // It's a status code object, normalize each value
406+ for ( const [ status , schema ] of Object . entries ( response ) ) {
407+ if ( typeof schema === 'string' ) {
408+ response [ status ] = normalizeSchemaReference ( schema )
409+ }
410+ }
411+ }
412+ }
413+
414+ return merged
415+ }
416+
417+ /**
418+ * Flatten routes by merging guard() schemas into direct hook properties.
419+ *
420+ * This makes guard() schemas accessible in the OpenAPI spec by converting
421+ * the standaloneValidator array into direct hook properties.
422+ */
423+ const flattenRoutes = ( routes : any [ ] ) : any [ ] => {
424+ return routes . map ( ( route ) => {
425+ if ( ! route . hooks ?. standaloneValidator ?. length ) {
426+ return route
427+ }
428+
429+ return {
430+ ...route ,
431+ hooks : mergeStandaloneValidators ( route . hooks )
432+ }
433+ } )
434+ }
435+
436+ // ============================================================================
437+
109438const unwrapReference = < T extends OpenAPIV3 . SchemaObject | undefined > (
110439 schema : T ,
111440 definitions : Record < string , unknown >
@@ -294,10 +623,9 @@ export function toOpenAPISchema(
294623 // @ts -ignore
295624 const definitions = app . getGlobalDefinitions ?.( ) . type
296625
297- // Use getFlattenedRoutes() if available (Elysia 1.4.16+) to get guard() schemas
298- // Falls back to getGlobalRoutes() for older versions
299- // @ts -expect-error - getFlattenedRoutes is a protected method not in public types
300- const routes = app . getFlattenedRoutes ?.( ) ?? app . getGlobalRoutes ( )
626+ // Flatten routes to merge guard() schemas into direct hook properties
627+ // This makes guard schemas accessible for OpenAPI documentation generation
628+ const routes = flattenRoutes ( app . getGlobalRoutes ( ) )
301629
302630 if ( references ) {
303631 if ( ! Array . isArray ( references ) ) references = [ references ]
0 commit comments