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
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"@sinclair/typemap": "^0.10.1",
"@types/bun": "1.2.20",
"effect": "^3.21.2",
"elysia": "1.4.19",
"elysia": "1.4.28",
"eslint": "9.6.0",
"openapi-types": "^12.1.3",
"tsup": "^8.5.1",
Expand Down
199 changes: 165 additions & 34 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,105 @@ export const enumToOpenApi = <
return schema as T
}

// ============================================================================
// Model Reference Resolution
// ============================================================================

const buildModelRefMap = (
definitions: Record<string, unknown> | undefined
) => {
const map = new Map<unknown, string>()

if (definitions)
for (const [name, schema] of Object.entries(definitions))
map.set(schema, name)

return map
}

const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) return true
if (a === null || b === null) return false
if (typeof a !== typeof b || typeof a !== 'object') return false

if (Array.isArray(a))
return Array.isArray(b)
&& a.length === b.length
&& a.every((v, i) => deepEqual(v, b[i]))

const aObj = a as Record<string, unknown>
const bObj = b as Record<string, unknown>
const aKeys = Object.keys(aObj)
const bKeys = Object.keys(bObj)

return aKeys.length === bKeys.length
&& aKeys.every((k) => deepEqual(aObj[k], bObj[k]))
}
Comment on lines +687 to +704
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep arrays out of the matcher, baka~

walk() can hand tryReplace() array-valued nodes like tags or required, and deepEqual only checks Array.isArray on the left-hand side. That makes cases like deepEqual({}, []) slip through, so a permissive model that normalizes to {} can rewrite unrelated arrays into $refs and break the spec. Tiny guard, big save~ ( ̄︶ ̄*)

♻️ Quick fix
 const deepEqual = (a: unknown, b: unknown): boolean => {
 	if (a === b) return true
 	if (a === null || b === null) return false
+	if (Array.isArray(a) !== Array.isArray(b)) return false
 	if (typeof a !== typeof b || typeof a !== 'object') return false

 	if (Array.isArray(a))
 		return Array.isArray(b)
 			&& a.length === b.length
 			&& a.every((v, i) => deepEqual(v, b[i]))
@@
 	const tryReplace = (
 		node: unknown,
 		parent: Record<string, unknown> | unknown[],
 		key: string | number
 	): boolean => {
-		if (!node || typeof node !== 'object') return false
+		if (!node || typeof node !== 'object' || Array.isArray(node))
+			return false

Also applies to: 728-744

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openapi.ts` around lines 687 - 704, The deepEqual function allows
mismatches like deepEqual({}, []) because it only checks Array.isArray on the
left side; update deepEqual (referenced by walk() and tryReplace(), which may
pass arrays like tags or required) to explicitly handle arrays on both sides: if
either value is an array, immediately return true only when both are arrays and
then compare lengths and elements (i.e., use if (Array.isArray(a) ||
Array.isArray(b)) { return Array.isArray(a) && Array.isArray(b) && a.length ===
b.length && a.every((v,i) => deepEqual(v, (b as unknown[])[i])); }), keeping the
existing null/type/object guards otherwise so objects and arrays cannot be
confused.


const stripSchemaMeta = (schema: Record<string, unknown>) => {
const { $schema: _s, $id: _i, ...rest } = schema
return rest
}

/**
* Walk paths and replace inline schemas matching a registered model
* with { $ref } pointers. Handles nested cases like z.array(Model).
*/
const resolveNestedModelRefs = (spec: {
components: { schemas: Record<string, unknown> }
paths: Record<string, unknown>
}) => {
const entries = Object.entries(spec.components.schemas)
if (!entries.length) return

const models = entries.map(([name, raw]) => {
const schema = stripSchemaMeta(raw as Record<string, unknown>)
spec.components.schemas[name] = schema
return { name, schema }
})

const tryReplace = (
node: unknown,
parent: Record<string, unknown> | unknown[],
key: string | number
): boolean => {
if (!node || typeof node !== 'object') return false

const obj = node as Record<string, unknown>
if ('$ref' in obj) return false

const cleaned = stripSchemaMeta(obj)

for (const model of models)
if (deepEqual(cleaned, model.schema)) {
(parent as Record<string | number, unknown>)[key] =
{ $ref: `#/components/schemas/${model.name}` }
return true
}

return false
}

const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return

if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++)
if (!tryReplace(node[i], node, i))
walk(node[i])
return
}

const obj = node as Record<string, unknown>

for (const key of Object.keys(obj))
if (!tryReplace(obj[key], obj, key))
walk(obj[key])
}

walk(spec.paths)
}

/**
* Converts Elysia routes to OpenAPI 3.0.3 paths schema
* @param routes Array of Elysia route objects
Expand Down Expand Up @@ -696,6 +795,7 @@ export function toOpenAPISchema(
const paths: OpenAPIV3.PathsObject = Object.create(null)
// @ts-ignore
const definitions = app.getGlobalDefinitions?.().type
const modelRefMap = buildModelRefMap(definitions)

if (references) {
if (!Array.isArray(references)) references = [references]
Expand Down Expand Up @@ -986,6 +1086,20 @@ export function toOpenAPISchema(
!(hooks.response as any)['~standard']
) {
for (let [status, schema] of Object.entries(hooks.response)) {
const modelName = modelRefMap.get(schema)

if (modelName) {
operation.responses[status] = {
description: `Response for status ${status}`,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` }
}
}
}
continue
Comment on lines +1089 to +1100
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't force every direct model ref through application/json, silly~

These fast paths bypass the existing unwrapSchema/type switch and always emit JSON. If a registered model is t.String(), z.string(), t.Null(), etc., using it directly as response now changes the contract from text/plain or no-content into application/json just because the identity match hit first. Reuse the same media-type selection and only swap the schema node to a $ref, got it? ♡

🩹 Minimal direction
-					if (modelName) {
-						operation.responses[status] = {
-							description: `Response for status ${status}`,
-							content: {
-								'application/json': {
-									schema: { $ref: `#/components/schemas/${modelName}` }
-								}
-							}
-						}
+					if (modelName) {
+						const response = unwrapSchema(schema as any, vendors, 'output')
+						const { type, description } = unwrapReference(response, definitions)
+						const ref = { $ref: `#/components/schemas/${modelName}` }
+
+						operation.responses[status] = {
+							description: description ?? `Response for status ${status}`,
+							content:
+								type === 'void' ||
+								type === 'null' ||
+								type === 'undefined'
+									? ({ type, description } as any)
+									: type === 'string' ||
+										  type === 'number' ||
+										  type === 'integer' ||
+										  type === 'boolean'
+										? { 'text/plain': { schema: ref } }
+										: { 'application/json': { schema: ref } }
+						}
 						continue
 					}

Apply the same shape in the single-response branch too.

Also applies to: 1136-1146

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openapi.ts` around lines 1089 - 1100, The fast-path that checks
modelRefMap.get(schema) and unconditionally sets
operation.responses[status].content['application/json'] is wrong; instead, when
a modelName is found reuse the existing media-type selection logic (the same
media-type keys and structure produced by your unwrapSchema/type switch) but
replace only the schema node with { $ref: `#/components/schemas/${modelName}` }.
Update the branch in the handler that populates operation.responses (the block
referencing modelRefMap, operation.responses and status) so it copies or
delegates to the media-type selection used by unwrapSchema/type and swaps in the
$ref rather than forcing 'application/json'; apply the same change to the
similar block around lines 1136-1146.

}

const response = unwrapSchema(schema, vendors, 'output')

if (!response) continue
Expand Down Expand Up @@ -1019,43 +1133,56 @@ export function toOpenAPISchema(
}
}
} else {
const response = unwrapSchema(
hooks.response as any,
vendors,
'output'
)

if (response) {
// @ts-ignore
const {
type: _type,
description,
...options
} = unwrapReference(response, definitions)
const type = _type as string | undefined
const modelName = modelRefMap.get(hooks.response)

// It's a single schema, default to 200
if (modelName) {
operation.responses['200'] = {
description: description ?? `Response for status 200`,
content:
type === 'void' ||
type === 'null' ||
type === 'undefined'
? ({ type, description } as any)
: type === 'string' ||
type === 'number' ||
type === 'integer' ||
type === 'boolean'
? {
'text/plain': {
schema: response
description: 'Response for status 200',
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` }
}
}
}
} else {
const response = unwrapSchema(
hooks.response as any,
vendors,
'output'
)

if (response) {
// @ts-ignore
const {
type: _type,
description,
...options
} = unwrapReference(response, definitions)
const type = _type as string | undefined

// It's a single schema, default to 200
operation.responses['200'] = {
description: description ?? `Response for status 200`,
content:
type === 'void' ||
type === 'null' ||
type === 'undefined'
? ({ type, description } as any)
: type === 'string' ||
type === 'number' ||
type === 'integer' ||
type === 'boolean'
? {
'text/plain': {
schema: response
}
}
}
: {
'application/json': {
schema: response
: {
'application/json': {
schema: response
}
}
}
}
}
}
}
Expand Down Expand Up @@ -1109,12 +1236,16 @@ export function toOpenAPISchema(
if (jsonSchema) schemas[name] = jsonSchema
}

return {
const result = {
components: {
schemas
},
paths
} satisfies Pick<OpenAPIV3.Document, 'paths' | 'components'>

resolveNestedModelRefs(result)

return result
}

export const withHeaders = (schema: TSchema, headers: TProperties) =>
Expand Down
Loading