Skip to content

feat(openapi): resolve registered models as $ref pointers#338

Open
marioparaschiv wants to merge 2 commits into
elysiajs:mainfrom
marioparaschiv:fix/preserve-model-refs
Open

feat(openapi): resolve registered models as $ref pointers#338
marioparaschiv wants to merge 2 commits into
elysiajs:mainfrom
marioparaschiv:fix/preserve-model-refs

Conversation

@marioparaschiv
Copy link
Copy Markdown

@marioparaschiv marioparaschiv commented May 20, 2026

Schemas registered via .model() are now matched against inline schemas
in route definitions and replaced with $ref pointers to
components/schemas. This reduces spec size and improves readability.

Direct model usage (e.g. response: User) is matched by object identity
on the raw schema before JSON Schema conversion. Nested usage (e.g.
response: z.array(User)) is matched by deep equality on the converted
JSON Schema output.

Works with all Standard Schema validators (Zod, TypeBox, Valibot, etc).

Summary by CodeRabbit

  • New Features

    • OpenAPI specification generation now automatically resolves and consolidates model schemas, converting inline definitions to component schema references for improved documentation cleanliness and standardized API representations.
  • Tests

    • Comprehensive test coverage added for model reference resolution, including TypeBox and Zod schemas, nested structures, and status-code-specific response mappings.
  • Chores

    • Updated elysia development dependency.

Review Change Stack

Schemas registered via .model() are now matched against inline schemas
in route definitions and replaced with $ref pointers to
components/schemas. This reduces spec size and improves readability.

Direct model usage (e.g. response: User) is matched by object identity
on the raw schema before JSON Schema conversion. Nested usage (e.g.
response: z.array(User)) is matched by deep equality on the converted
JSON Schema output.

Works with all Standard Schema validators (Zod, TypeBox, Valibot, etc).
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (24)
  • dist/cjs/gen/index.js is excluded by !**/dist/**, !**/gen/**
  • dist/cjs/index.d.ts is excluded by !**/dist/**
  • dist/cjs/index.js is excluded by !**/dist/**
  • dist/cjs/openapi.d.ts is excluded by !**/dist/**
  • dist/cjs/openapi.js is excluded by !**/dist/**
  • dist/cjs/scalar/index.js is excluded by !**/dist/**
  • dist/cjs/swagger/index.js is excluded by !**/dist/**
  • dist/cjs/swagger/types.js is excluded by !**/dist/**
  • dist/cjs/types.d.ts is excluded by !**/dist/**
  • dist/cjs/types.js is excluded by !**/dist/**
  • dist/gen/index.d.ts is excluded by !**/dist/**, !**/gen/**
  • dist/gen/index.mjs is excluded by !**/dist/**, !**/gen/**
  • dist/index.d.ts is excluded by !**/dist/**
  • dist/index.mjs is excluded by !**/dist/**
  • dist/openapi.d.ts is excluded by !**/dist/**
  • dist/openapi.mjs is excluded by !**/dist/**
  • dist/scalar/index.d.ts is excluded by !**/dist/**
  • dist/scalar/index.mjs is excluded by !**/dist/**
  • dist/swagger/index.d.ts is excluded by !**/dist/**
  • dist/swagger/index.mjs is excluded by !**/dist/**
  • dist/swagger/types.d.ts is excluded by !**/dist/**
  • dist/swagger/types.mjs is excluded by !**/dist/**
  • dist/types.d.ts is excluded by !**/dist/**
  • dist/types.mjs is excluded by !**/dist/**

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3183e8aa-0452-4ee9-94a9-25937970191f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Youuuu thought you could just casually submit an OpenAPI schema generation improvement~? How adorable. ᐕ)ノ This PR replaces duplicate inline schemas with component $ref pointers, streamlines response generation for both status-code maps and single responses, and validates everything with a comprehensive test suite. Elysia dependency gets a version bump too. Pretty straightforward for someone of my level, honestly♡

Changes

Model Reference Resolution Feature

Layer / File(s) Summary
Model Reference Resolution Helpers
src/openapi.ts
Adds buildModelRefMap to map schemas to model names, schemaMatches for structural schema comparison, and resolveNestedModelRefs to walk the specification and replace matching inline schemas with { $ref: '#/components/schemas/<name>' } pointers.
Response Generation Integration
src/openapi.ts
Constructs the model reference map in toOpenAPISchema and checks whether each response schema (both in status-code maps and single-response form) matches a registered component model; emits simplified application/json $ref responses when matches are found instead of using inline schema unwrapping.
Final Document Assembly with Model Resolution
src/openapi.ts
Calls resolveNestedModelRefs before returning the final OpenAPI result to convert any remaining matching inline schemas under paths into component $ref pointers.
Model Reference Test Suite
test/model-refs.test.ts
Validates $ref substitution for direct and nested models (TypeBox t.Object and Zod schemas), status-code response maps, non-matching schema non-replacement, component registration under components/schemas, and independence across multiple registered models.
Dependency Version Update
package.json
Updates elysia dev dependency from 1.4.19 to 1.4.28.

Sequence Diagram

sequenceDiagram
  participant Gen as OpenAPI Generator
  participant Map as buildModelRefMap
  participant Match as schemaMatches
  participant Emit as Response Emitter
  participant Resolve as resolveNestedModelRefs
  
  Gen->>Map: Create schema→name map from definitions
  Map-->>Gen: modelRefMap ready
  Gen->>Gen: Generate paths & response schemas
  Gen->>Match: Check if response matches registered model
  Match-->>Gen: match found or not
  alt Match Found
    Gen->>Emit: Emit $ref response
    Emit-->>Gen: application/json with $ref
  else No Match
    Gen->>Emit: Emit inline schema response
    Emit-->>Gen: response with unwrapped content
  end
  Gen->>Resolve: Convert remaining inline schemas
  Resolve-->>Gen: specification with nested $refs resolved
  Gen-->>Gen: Return final OpenAPI document
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

You think you're so clever adding model refs~? ♡
Schemas are shrinking, $ref pointers flexing~
TypeBox and Zod bow before my design sense...
But I suppose your tests aren't totally bad~ (´▽`❤)ノ
Fine fine, I'll admit it—this is kinda neat♡

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(openapi): resolve registered models as $ref pointers' accurately and specifically summarizes the main change: implementing model reference resolution to convert inline schemas into $ref pointers in OpenAPI specs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/openapi.ts`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1cccd0d6-2558-4cea-a636-bb3777694206

📥 Commits

Reviewing files that changed from the base of the PR and between dabaa94 and f37137b.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • package.json
  • src/openapi.ts
  • test/model-refs.test.ts

Comment thread src/openapi.ts
Comment on lines +687 to +704
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]))
}
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.

Comment thread src/openapi.ts
Comment on lines +1089 to +1100
const modelName = modelRefMap.get(schema)

if (modelName) {
operation.responses[status] = {
description: `Response for status ${status}`,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` }
}
}
}
continue
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.

@marioparaschiv marioparaschiv force-pushed the fix/preserve-model-refs branch from b53a8b9 to f37137b Compare May 20, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant