-
Notifications
You must be signed in to change notification settings - Fork 106
feat(openapi): resolve registered models as $ref pointers #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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])) | ||
| } | ||
|
|
||
| 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 | ||
|
|
@@ -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] | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't force every direct model ref through These fast paths bypass the existing 🩹 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 |
||
| } | ||
|
|
||
| const response = unwrapSchema(schema, vendors, 'output') | ||
|
|
||
| if (!response) continue | ||
|
|
@@ -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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -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) => | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep arrays out of the matcher, baka~
walk()can handtryReplace()array-valued nodes liketagsorrequired, anddeepEqualonly checksArray.isArrayon the left-hand side. That makes cases likedeepEqual({}, [])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 falseAlso applies to: 728-744
🤖 Prompt for AI Agents