Skip to content

Conversation

@MarcelOlsen
Copy link
Contributor

@MarcelOlsen MarcelOlsen commented Nov 6, 2025

This resolves #1490

Problem

Guard schemas defined via guard() are not accessible to plugins like OpenAPI. Routes appear in documentation but lack request body and parameter schemas.

Current Behavior

app.guard({
  body: t.Object({
    username: t.String(),
    password: t.String()
  })
}, (app) =>
  app
    .post('/sign-up', ({ body }) => body)
    .post('/sign-in', ({ body }) => body)
)
  • Runtime: Works correctly (validation, types)
  • OpenAPI: Routes exist but have no requestBody schema

Root Cause

Guard schemas are stored in route.hooks.standaloneValidator[] arrays. Plugins only read direct schema properties like route.hooks.body.

Solution

Add getFlattenedRoutes() method that merges standaloneValidator arrays into direct hook properties.

Changes

New Methods:

  • getFlattenedRoutes(): Returns routes with merged schemas (protected)
  • mergeStandaloneValidators(): Flattens standaloneValidator arrays
  • mergeSchemaProperty(): Handles TypeBox schema merging
  • mergeResponseSchema(): Handles response status codes

Tests:

  • test/core/flattened-routes.test.ts: 7 tests covering all schema types and edge cases

Backward Compatibility

  • getGlobalRoutes() unchanged - existing behavior preserved
  • Zero breaking changes - purely additive
  • Plugins can use app.getFlattenedRoutes?.() ?? app.getGlobalRoutes() for compatibility

Usage

Plugins can now access guard schemas:

// Before: standaloneValidator only
const routes = app.getGlobalRoutes()
// routes[0].hooks.body === undefined
// routes[0].hooks.standaloneValidator === [{ body: {...} }]

// After: schemas merged
const routes = app.getFlattenedRoutes()
// routes[0].hooks.body === { type: 'object', properties: {...} }
// routes[0].hooks.standaloneValidator === [{ body: {...} }] (preserved)

Demo

Reproduction case and full documentation: https://github.com/MarcelOlsen/elysia-guard-openapi-fix-demo

Related PR

OpenAPI plugin PR: elysiajs/elysia-openapi (to be created)

Testing

bun test test/core/flattened-routes.test.ts

All 7 tests pass.

Summary by CodeRabbit

  • Chores

    • Consolidated standalone validator schemas into route-level hooks, improving how request and response schemas are merged and applied across body, headers, query, params, and cookies — including merging and overriding behavior for status-specific response definitions.
  • Tests

    • Added comprehensive tests validating flattened route behavior, merged validator schemas, nested/combined schema scenarios, and correct handling of status-coded responses.

Fixes guard() schemas not being accessible to plugins like OpenAPI.

Changes:
- Add getFlattenedRoutes() method that merges standaloneValidator arrays
  into direct hook properties
- Add mergeStandaloneValidators() helper for schema merging
- Add mergeSchemaProperty() for TypeBox object merging
- Add mergeResponseSchema() for response status code handling
- Add comprehensive test suite (7 tests, all passing)

This allows plugins to access guard() schemas that were previously stored
only in standaloneValidator arrays and invisible to external consumers.

Backward compatible - getGlobalRoutes() behavior unchanged.
@coderabbitai
Copy link

coderabbitai bot commented Nov 6, 2025

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

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

Walkthrough

Adds internal utilities to flatten routes by merging guard/group standalone validator schemas into route hook properties and response maps; includes tests validating merge behaviors across body, headers, query, params, cookie, and status-code response shapes.

Changes

Cohort / File(s) Summary
Core flattening & merge logic
src/index.ts
Introduced getFlattenedRoutes plus helpers: mergeStandaloneValidators, mergeSchemaProperty, and mergeResponseSchema. These merge ancestor standaloneValidator entries into route hook props (body, headers, query, params, cookie) and normalize/merge response schemas (string refs, object schemas, and status-code maps).
Tests
test/core/flattened-routes.test.ts
Added comprehensive tests for getFlattenedRoutes covering merging from guards/groups, nested guards, combining guard and direct schemas, query/params/cookie handling, and response merging/precedence for 200 vs non-200 status codes.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant App as Application
  participant Routes as RouteDefs
  participant Merger as getFlattenedRoutes
  participant Schema as SchemaFragments

  Note over Routes,Merger: Collect ancestor standaloneValidators for each route
  Routes->>Merger: route + [standaloneValidators]
  Merger->>Schema: extract parts (body, headers, query, params, cookie, response)
  alt simple property (body/headers/query/params/cookie)
    Merger->>Merger: mergeSchemaProperty(prop)  %%[`#8FB9A8`]%%
  else response merging
    Merger->>Merger: mergeResponseSchema(response)  %%[`#F2D388`]%%
  end
  Merger-->>Routes: flattened route with merged hook properties and unified response map
  Routes-->>App: application uses flattened routes (runtime and docs/spec generation)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Pay attention to: correctness of intersection/merge logic in mergeSchemaProperty (handling string refs vs object schemas).
  • Validate mergeResponseSchema behavior for status-code keyed maps, and 200 vs non-200 precedence.
  • Verify tests cover nested/overlapping guards and edge cases.

Possibly related PRs

  • 1.4.5 patch #1403 — Related changes to standalone guard/group schema handling and merging logic; likely overlaps with the flattening behavior introduced here.

Poem

🐰 I hop through guards and stitch the seams,

I fold their schemas into single streams.
Routes once nested, now tidy and plain,
Responses merged, no crumbs remain.
A little rabbit, merging in vain and in gain.

Pre-merge checks and finishing touches

✅ 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 accurately describes the main change: exposing guard() schemas to plugins via a new getFlattenedRoutes() method, which directly addresses the PR's objective.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #1490: exposing guard() schemas to plugins, preserving runtime behavior, maintaining backward compatibility, and providing guidance for plugin integration.
Out of Scope Changes check ✅ Passed All changes are directly scoped to exposing guard() schemas via getFlattenedRoutes(); no unrelated modifications detected in the implementation or tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Nov 6, 2025

Open in StackBlitz

npm i https://pkg.pr.new/elysiajs/elysia@1533

commit: f2b3f63

Copy link

@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: 1

🧹 Nitpick comments (3)
test/core/flattened-routes.test.ts (2)

6-37: Consider verifying standaloneValidator preservation.

According to the PR description, "The standaloneValidator arrays are preserved; merged properties are exposed alongside them." This test should verify that signUpRoute?.hooks.standaloneValidator still exists after flattening to ensure the original data isn't lost.

 	// Verify it's an object schema with the expected properties
 	expect(signUpRoute?.hooks.body.type).toBe('object')
 	expect(signUpRoute?.hooks.body.properties).toHaveProperty('username')
 	expect(signUpRoute?.hooks.body.properties).toHaveProperty('password')
+
+	// Verify standaloneValidator is preserved
+	expect(signUpRoute?.hooks.standaloneValidator).toBeDefined()
+	expect(signUpRoute?.hooks.standaloneValidator?.length).toBeGreaterThan(0)

5-171: Add tests for schema merging edge cases.

The current test suite covers happy paths well but is missing tests for important edge cases in the merging logic:

  1. Schema merging conflicts: When both guard and direct route define the same schema (e.g., both have body), test that they merge correctly
  2. String schema references: Test behavior when schemas are string references to models
  3. Non-object schemas: Test that non-object schemas create Intersect types correctly
  4. Complex response schemas: Test merging of response schemas with multiple status codes

These edge cases are particularly important since mergeSchemaProperty and mergeResponseSchema have specific logic to handle them.

Example test for schema merging conflict:

it('merges body schemas from guard and direct route', () => {
	const app = new Elysia().guard(
		{
			body: t.Object({
				token: t.String()
			})
		},
		(app) =>
			app.post('/data', ({ body }) => body, {
				body: t.Object({
					data: t.String()
				})
			})
	)

	// @ts-expect-error - accessing protected method for testing
	const flatRoutes = app.getFlattenedRoutes()
	const dataRoute = flatRoutes.find((r) => r.path === '/data')

	// Should have both token and data properties
	expect(dataRoute?.hooks.body.properties).toHaveProperty('token')
	expect(dataRoute?.hooks.body.properties).toHaveProperty('data')
})
src/index.ts (1)

417-420: Consider documenting string reference override behavior.

When either schema is a string reference, the function returns incoming, potentially discarding a valid existing schema. For example:

  • existing = t.Object({ id: t.String() })
  • incoming = 'UserModel'
  • Result: 'UserModel' (loses the inline object schema)

If this override behavior is intentional, consider adding a JSDoc comment explaining the rationale. If not, you might want to log a warning or handle this case differently.

 	// If either is a string reference, we can't merge - use incoming
 	if (typeof existing === 'string' || typeof incoming === 'string') {
+		// String references cannot be merged with schemas, so incoming takes precedence
 		return incoming
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1084b5f and 12b1739.

📒 Files selected for processing (2)
  • src/index.ts (2 hunks)
  • test/core/flattened-routes.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
test/core/flattened-routes.test.ts (1)
src/index.ts (1)
  • t (8256-8256)
src/index.ts (2)
src/types.ts (2)
  • InternalRoute (1878-1886)
  • AnyLocalHook (1680-1680)
src/schema.ts (1)
  • mergeObjectSchemas (1403-1474)
🔇 Additional comments (3)
src/index.ts (3)

337-354: LGTM!

The getFlattenedRoutes() implementation is clean and efficient. The early return optimization for routes without standaloneValidator is good, and the shallow copy with hook replacement preserves the original route data properly.


359-405: LGTM!

The mergeStandaloneValidators() method correctly iterates through the standaloneValidator array and delegates to appropriate merge functions for each schema type. The conditional checks prevent unnecessary merging operations.


52-53: LGTM!

The mergeObjectSchemas import is correctly placed and used in mergeSchemaProperty() for schema composition.

MarcelOlsen added a commit to MarcelOlsen/elysia-openapi that referenced this pull request Nov 6, 2025
… spec

Fixes guard() schemas not appearing in generated OpenAPI documentation.

Changes:
- Replace app.getGlobalRoutes() with app.getFlattenedRoutes?.() ?? app.getGlobalRoutes()
- Maintains backward compatibility with older Elysia versions

This allows the OpenAPI plugin to access guard() schemas that are now
exposed via Elysia's getFlattenedRoutes() method.

Requires: elysiajs/elysia#1533
@MarcelOlsen MarcelOlsen marked this pull request as draft November 6, 2025 22:13
MarcelOlsen added a commit to MarcelOlsen/elysia that referenced this pull request Nov 7, 2025
   Addresses feedback from coderabbitai on PR elysiajs#1533.

   **Problem:**
   When merging a status-code map with a plain schema, the original
   implementation would discard the entire status-code map, losing
   schemas for error responses (404, 500, etc.).

   **Solution:**
   Updated mergeResponseSchema logic to preserve status codes while
   treating plain schemas as 200:
   - Both status maps → merge with incoming winning conflicts
   - Status map + plain schema → treat plain as 200, preserve other codes
   - Both plain schemas → use incoming

   **Testing:**
   Added test case verifying 404/500 schemas are preserved when route
   defines a plain 200 response schema.
@MarcelOlsen
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@MarcelOlsen MarcelOlsen marked this pull request as ready for review November 7, 2025 09:38
@MarcelOlsen MarcelOlsen force-pushed the fix/expose-guard-schemas-to-plugins branch from 32f640f to 8cdde76 Compare November 7, 2025 20:45
Copy link

@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: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 32f640f and 8cdde76.

📒 Files selected for processing (2)
  • src/index.ts (2 hunks)
  • test/core/flattened-routes.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/core/flattened-routes.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/index.ts (3)
src/types.ts (2)
  • InternalRoute (1878-1886)
  • AnyLocalHook (1680-1680)
src/schema.ts (1)
  • mergeObjectSchemas (1403-1474)
src/type-system/index.ts (1)
  • t (673-673)

Copy link

@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: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8cdde76 and 4a68446.

📒 Files selected for processing (1)
  • src/index.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/index.ts (2)
src/types.ts (2)
  • InternalRoute (1878-1886)
  • AnyLocalHook (1680-1680)
src/schema.ts (1)
  • mergeObjectSchemas (1403-1474)
🪛 GitHub Actions: Build and Test
src/index.ts

[error] 491-491: src/index.ts(491,10): error TS7053: Element implicitly has an 'any' type because expression of type '200' can't be used to index type 'TSchema | { [status: number]: TSchema; } | { [status: number]: string | TSchema; }'. Property '200' does not exist on type 'TSchema | { [status: number]: TSchema; } | { [status: number]: string | TSchema; }'.

🔇 Additional comments (4)
src/index.ts (4)

52-53: LGTM: Import added correctly.

The mergeObjectSchemas import is properly added and used in the mergeSchemaProperty method (line 423).


337-354: LGTM: Clean implementation of getFlattenedRoutes().

The method correctly:

  • Maintains backward compatibility by preserving routes without standaloneValidator
  • Returns a new route object with merged hooks rather than mutating the original
  • Matches the return type of getGlobalRoutes()

359-405: LGTM: Solid implementation of validator merging.

The method correctly:

  • Creates a shallow copy to avoid mutation
  • Optimizes with early return for empty validators
  • Delegates to specialized merge methods for each schema type
  • Preserves the original standaloneValidator array

410-437: LGTM: Schema property merging is well-designed.

The method handles all cases appropriately:

  • Early returns for undefined values
  • Correctly rejects merging string references
  • Uses mergeObjectSchemas utility for object schemas
  • Properly wraps non-object schemas in Intersect

@MarcelOlsen
Copy link
Contributor Author

MarcelOlsen commented Nov 7, 2025

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@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: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6cf634d and add0f57.

📒 Files selected for processing (1)
  • src/index.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/index.ts (3)
src/types.ts (2)
  • InternalRoute (1878-1886)
  • AnyLocalHook (1680-1680)
src/schema.ts (1)
  • mergeObjectSchemas (1403-1474)
src/type-system/index.ts (1)
  • t (673-673)

@MarcelOlsen
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@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: 1

♻️ Duplicate comments (1)
src/index.ts (1)

488-495: Guard plain schema unconditionally overwrites route's status-200 response.

When existing is a plain schema (route-specific) and incoming is a status-code map (guard), Line 493 unconditionally sets 200: existing, discarding incoming[200] if it already exists. This breaks route-defined 200-status responses. The past review flagged this exact issue and marked it as "✅ Addressed in commit 4a68446," but the fix is not present in the current code.

Apply this diff to preserve route-defined status-code entries:

 if (existingIsSchema && !incomingIsSchema) {
-    return {
-        ...incoming,
-        200: existing
-    }
+    return (incoming as Record<number, TSchema | string>)[200] === undefined
+        ? {
+                ...incoming,
+                200: existing
+            }
+        : incoming
 }
🧹 Nitpick comments (1)
src/index.ts (1)

469-470: Fragile type detection for TSchema vs status-code map.

Using 'type' in existing to distinguish between a plain TSchema and a status-code object is unreliable:

  • A status-code map could theoretically have a numeric key named type (unlikely but not impossible)
  • Not all TSchema instances guarantee a type property (refs, unions, etc.)

Consider using TypeBox's Kind symbol for more robust detection:

-const existingIsSchema = 'type' in existing
-const incomingIsSchema = 'type' in incoming
+const existingIsSchema = Kind in existing
+const incomingIsSchema = Kind in incoming
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between add0f57 and f2b3f63.

📒 Files selected for processing (1)
  • src/index.ts (2 hunks)
🔇 Additional comments (5)
src/index.ts (5)

52-53: Verify imports are used correctly.

The new imports queryCoercions and mergeObjectSchemas are added but not directly visible in the changed code segments. Confirm these are indeed used in the new merge helper methods.


337-354: LGTM: Clean implementation of route flattening.

The getFlattenedRoutes() method correctly maps routes and merges standalone validators only when present, preserving the original route structure when no merging is needed.


497-501: LGTM: Status-code map merge gives route precedence.

When both schemas are status-code maps, spreading ...incoming first then ...existing correctly gives the accumulated route-specific state precedence over guard defaults. This matches the expected semantics where route definitions override guards.


410-437: Verify mergeObjectSchemas behavior for edge cases.

The logic handles string references, object merging, and Intersect creation. Confirm that mergeObjectSchemas gracefully handles:

  • Empty object schemas
  • Circular references
  • Incompatible object shapes

If mergeObjectSchemas returns { schema: undefined, notObjects: [] }, Line 436 returns undefined—verify this is the intended behavior when both inputs are objects but cannot be merged.


359-405: Validate merge iteration order matches guard application order.

mergeStandaloneValidators iterates the standaloneValidator array in order, merging each entry into the accumulated state. Confirm that the array order matches the intended precedence:

  • Route-specific validators should come first (higher precedence)
  • Guard/group validators should come later (fill gaps only)

Looking at the guard() implementation (lines 4780-4792), guard schemas are appended to the array, suggesting they come after route validators. Verify this ordering is correct and that the merge helpers give appropriate precedence to earlier entries (route) over later ones (guards).

src/index.ts Outdated
Comment on lines 472 to 475
// If both are plain schemas, incoming overrides
if (existingIsSchema && incomingIsSchema) {
return incoming
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Route-specific plain response schemas are overwritten by guards.

When both schemas are plain (not status-code maps), incoming overrides existing. Since existing is the accumulated state starting with the route's direct schema and incoming is the guard schema being merged, this gives guards precedence over route-specific definitions. For plugins consuming flattened routes, route-specific schemas should take precedence.

Apply this diff:

 // If both are plain schemas, incoming overrides
 if (existingIsSchema && incomingIsSchema) {
-    return incoming
+    return existing
 }
🤖 Prompt for AI Agents
In src/index.ts around lines 472 to 475, the current logic returns incoming when
both existingIsSchema and incomingIsSchema are true, causing guard schemas to
overwrite route-specific plain schemas; change this so that when both are plain
schemas the existing schema (the route's accumulated/direct schema) is preserved
by returning existing instead of incoming, ensuring route-specific definitions
take precedence over guard-provided schemas.

@MarcelOlsen MarcelOlsen marked this pull request as draft November 9, 2025 20:33
@MarcelOlsen
Copy link
Contributor Author

closing, because i've reopened form a fresh branch ;)

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.

OpenAPI: schemas defined via guard() are missing from generated spec

1 participant