Skip to content

Commit e986f1b

Browse files
committed
🔧 fix: implement guard schema flattening locally
- Add flattenRoutes() and schema merging helpers to elysia-openapi - Replace app.getFlattenedRoutes() call with local implementation - Use app.getGlobalRoutes() and flatten routes internally - Enables guard() schemas to appear in OpenAPI documentation - Allows future support for external schema conversions (z.toJSONSchema, etc.) This moves the flattening logic from elysia core to the OpenAPI plugin where it belongs, as suggested by the maintainer.
1 parent da02e4d commit e986f1b

File tree

2 files changed

+422
-5
lines changed

2 files changed

+422
-5
lines changed

src/openapi.ts

Lines changed: 333 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { t, type AnyElysia, type TSchema, type InputSchema } from 'elysia'
22
import type { HookContainer, StandardSchemaV1Like } from 'elysia/types'
33

44
import 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

77
import type {
88
AdditionalReference,
@@ -106,6 +106,335 @@ openapi({
106106

107107
const 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+
109438
const 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

Comments
 (0)