Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/add-order-by-direction-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chanfana": patch
---

Export `OrderByDirection` type alias (`"asc" | "desc"`) so consumers can import it directly instead of inlining literal unions
26 changes: 26 additions & 0 deletions .changeset/add-passthrough-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"chanfana": patch
---

Add `passthroughErrors` option to bypass chanfana's error handling and let errors propagate raw to the framework's error handler

```typescript
import { Hono } from "hono";
import { fromHono, ApiException } from "chanfana";
import { ZodError } from "zod";

const app = new Hono();

app.onError((err, c) => {
// Errors arrive as raw exceptions — no HTTPException wrapping
if (err instanceof ApiException) {
return c.json({ ok: false, code: err.code, message: err.message }, err.status as any);
}
if (err instanceof ZodError) {
return c.json({ ok: false, validationErrors: err.issues }, 400);
}
return c.json({ ok: false, message: "Internal Server Error" }, 500);
});

const openapi = fromHono(app, { passthroughErrors: true });
```
2 changes: 1 addition & 1 deletion docs/endpoints/auto/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ You can customize auto endpoints by:
* `searchFields` - Fields available for full-text search
* `orderByFields` - Fields available for sorting
* `defaultOrderBy` - Default sort field when none specified
* `defaultOrderByDirection` - Default sort direction: `"asc"` or `"desc"` (defaults to `"asc"`)
* `defaultOrderByDirection` - Default sort direction: `OrderByDirection` (`"asc"` or `"desc"`, defaults to `"asc"`)
* `searchFieldName` - Customize the search query parameter name (defaults to `'search'`)
* **Using `serializer` and `serializerSchema` in `Meta`:** Transform output data and hide sensitive fields before sending responses.
* **Using `pathParameters` in `Meta`:** Explicitly define path parameters, especially for nested routes with composite primary keys.
Expand Down
61 changes: 61 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,10 @@ The returned value is used for all subsequent error handling:
- If `raiseOnError` is true (Hono adapter), the returned error is re-thrown to Hono's `onError`.
- Otherwise, chanfana's `formatChanfanaError` is called on the returned error.

::: tip
When [`passthroughErrors`](./openapi-configuration-customization.md#passthrougherrors-bypass-chanfana-error-handling) is enabled, the `handleError()` hook is skipped entirely — errors propagate raw from `handle()` without any transformation.
:::

**Example: Custom error wrapping for Hono**

A common pattern is wrapping `ApiException` in a custom error class so that it bypasses chanfana's built-in formatter and reaches Hono's `onError`, where you can apply your own response format:
Expand Down Expand Up @@ -672,6 +676,63 @@ In this example, `app.onError` receives all errors thrown within Hono routes:
Unknown errors that are not `ZodError` or `ApiException` subclasses are **not** wrapped in `HTTPException` -- they propagate to `onError` as-is.
:::

#### Bypassing Chanfana's Error Formatting

If you want errors to propagate to Hono's `onError` without chanfana touching them at all — no `handleError()` hook, no JSON formatting, no `HTTPException` wrapping — use the [`passthroughErrors`](./openapi-configuration-customization.md#passthrougherrors-bypass-chanfana-error-handling) option:

```typescript
import { Hono, type Context } from 'hono';
import { fromHono, NotFoundException, ApiException } from 'chanfana';
import { ZodError } from 'zod';

export type Env = {
// Example bindings, use your own
DB: D1Database
BUCKET: R2Bucket
}
export type AppContext = Context<{ Bindings: Env }>

const app = new Hono<{ Bindings: Env }>();

app.onError((err, c) => {
console.error("Raw error:", err);

// Chanfana exceptions arrive as-is — not wrapped in HTTPException
if (err instanceof ApiException) {
return c.json({
ok: false,
code: err.code,
message: err.message,
}, err.status as any);
}

// Validation errors arrive as raw ZodError
if (err instanceof ZodError) {
return c.json({
ok: false,
validationErrors: err.issues,
}, 400);
}

return c.json({ ok: false, message: 'Internal Server Error' }, 500);
});

const openapi = fromHono(app, { passthroughErrors: true });
```

With `passthroughErrors: true`, chanfana's entire error pipeline is bypassed:

1. **`handleError()` hook** — skipped entirely
2. **`formatChanfanaError()`** — skipped entirely
3. **`HTTPException` wrapping** (Hono adapter) — skipped entirely

This means `err instanceof HTTPException` will **not** match chanfana errors in your `onError` handler. You handle the raw exception types directly (`ApiException`, `ZodError`, `Error`, etc.).

This is useful when you want to:
- Apply your own error response format instead of chanfana's standard `{ success, errors, result }` shape
- Integrate with a shared error handler that doesn't understand `HTTPException`
- Handle `ZodError` validation failures with your own logic

### itty-router: Internal Error Formatting

itty-router does not have an `onError` mechanism. When using `fromIttyRouter()`, chanfana catches errors internally and formats them into JSON responses directly -- the same behavior as previous versions. No additional error handling setup is needed.
Expand Down
29 changes: 29 additions & 0 deletions docs/examples-and-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,35 @@ app.onError((err, c) => {
const openapi = fromHono(app)
```

### Hono: Raw Error Passthrough

If you want full control over error responses and don't want chanfana to format or wrap errors, use `passthroughErrors`. Errors propagate as raw exceptions to Hono's `onError` — no `HTTPException` wrapping, no JSON formatting:

```ts
import { Hono } from 'hono'
import { fromHono, ApiException } from 'chanfana'
import { ZodError } from 'zod'

const app = new Hono()

app.onError((err, c) => {
// Errors arrive as raw exceptions — handle them directly
if (err instanceof ApiException) {
return c.json({ ok: false, code: err.code, message: err.message }, err.status as any)
}

if (err instanceof ZodError) {
return c.json({ ok: false, validationErrors: err.issues }, 400)
}

return c.json({ ok: false, message: 'Internal Server Error' }, 500)
})

const openapi = fromHono(app, { passthroughErrors: true })
```

See [Bypassing Chanfana's Error Formatting](./error-handling.md#bypassing-chanfanas-error-formatting) for a detailed explanation.

### Reusing error handlers across the project

Define a base class that extends `OpenAPIRoute` with shared logic:
Expand Down
36 changes: 36 additions & 0 deletions docs/openapi-configuration-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,42 @@ The `raiseOnError` option controls whether chanfana re-throws errors instead of

See [Error Handling - Global Error Handling Strategies](./error-handling.md#global-error-handling-strategies) for usage details.

### `passthroughErrors`: Bypass Chanfana Error Handling

* **Type:** `boolean`
* **Default:** `false`

The `passthroughErrors` option disables all of chanfana's error handling. When set to `true`, errors thrown during `handle()` (or during request validation) propagate as-is to the framework's error handler — chanfana does not catch, format, or wrap them in any way.

* `true`: Errors are re-thrown immediately from `execute()` without any processing. The `handleError()` hook, `formatChanfanaError()`, and Hono's `HTTPException` wrapping are all skipped. Raw exceptions (`ApiException`, `ZodError`, `Error`, etc.) reach Hono's `app.onError` directly.
* `false`: (Default) Chanfana handles errors normally — formatting them into JSON responses or wrapping them as `HTTPException` for Hono.

**Example:**

```typescript
import { Hono } from 'hono';
import { fromHono, NotFoundException } from 'chanfana';

const app = new Hono();

app.onError((err, c) => {
// Errors arrive as raw exceptions — NotFoundException, ZodError, etc.
// No HTTPException wrapping, no chanfana JSON formatting.
if (err instanceof NotFoundException) {
return c.json({ ok: false, message: err.message }, 404);
}
return c.json({ ok: false, message: 'Internal Server Error' }, 500);
});

const openapi = fromHono(app, { passthroughErrors: true });
```

::: tip
This option is most useful with Hono, where `app.onError` provides centralized error handling. With itty-router (which has no `onError` mechanism), enabling `passthroughErrors` causes errors to propagate unhandled.
:::

See [Error Handling - Bypassing Chanfana's Error Formatting](./error-handling.md#bypassing-chanfanas-error-formatting) for a detailed walkthrough.

## Customizing OpenAPI Schema Output

While `RouterOptions` allows you to configure the overall OpenAPI document, you can also customize the schema output for individual endpoints and parameters using Zod's OpenAPI metadata features.
Expand Down
8 changes: 8 additions & 0 deletions docs/router-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ app.onError((err, c) => {
const openapi = fromHono(app);
```

If you want errors to reach `onError` without chanfana formatting or wrapping them, use the `passthroughErrors` option:

```typescript
const openapi = fromHono(app, { passthroughErrors: true });
```

With this flag, raw exceptions (`NotFoundException`, `ZodError`, etc.) propagate directly to `onError` — no `HTTPException` wrapping, no JSON formatting. See [Bypassing Chanfana's Error Formatting](./error-handling.md#bypassing-chanfanas-error-formatting) for details.

See [Error Handling - Global Error Handling Strategies](./error-handling.md#global-error-handling-strategies) for more details.

### `HonoOpenAPIRouterType`
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export class HonoOpenAPIHandler extends OpenAPIHandler {
* error response format via HTTPException.getResponse().
*/
protected wrapHandler(handler: (...args: any[]) => Promise<Response>): (...args: any[]) => Promise<Response> {
if (this.options?.passthroughErrors) return handler;

return async (...args: any[]) => {
try {
return await handler(...args);
Expand Down
7 changes: 4 additions & 3 deletions src/endpoints/d1/base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiException, InputValidationException } from "../../exceptions";
import type { OrderByDirection } from "../../types";
import type { FilterCondition, Filters, Logger } from "../types";

/**
Expand Down Expand Up @@ -86,9 +87,9 @@ export function validateColumnName(columnName: string, validColumns?: string[]):
* @param direction - The direction string to validate
* @returns "asc" or "desc"
*/
export function validateOrderDirection(direction: string | undefined): "asc" | "desc" {
export function validateOrderDirection(direction: string | undefined): OrderByDirection {
const normalized = (direction || "asc").toLowerCase().trim();
return VALID_ORDER_DIRECTIONS.has(normalized) ? (normalized as "asc" | "desc") : "asc";
return VALID_ORDER_DIRECTIONS.has(normalized) ? (normalized as OrderByDirection) : "asc";
}

/**
Expand Down Expand Up @@ -254,6 +255,6 @@ export function buildWhereClause(conditions: string[]): string {
* @param direction - Validated direction ("asc" or "desc")
* @returns ORDER BY clause string
*/
export function buildOrderByClause(column: string, direction: "asc" | "desc"): string {
export function buildOrderByClause(column: string, direction: OrderByDirection): string {
return `ORDER BY ${column} ${direction}`;
}
4 changes: 2 additions & 2 deletions src/endpoints/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from "zod";
import { contentJson } from "../contentTypes";
import { InputValidationException } from "../exceptions";
import { OpenAPIRoute } from "../route";
import type { AnyZodObject } from "../types";
import type { AnyZodObject, OrderByDirection } from "../types";
import {
type FilterCondition,
type ListFilters,
Expand All @@ -29,7 +29,7 @@ export class ListEndpoint<HandleArgs extends Array<object> = Array<object>> exte
orderByFields: string[] = [];
defaultOrderBy?: string;
/** Default sort direction when order_by is used. Defaults to "asc". */
defaultOrderByDirection: "asc" | "desc" = "asc";
defaultOrderByDirection: OrderByDirection = "asc";

getSchema() {
const parsedQueryParameters = this.meta.fields.pick(
Expand Down
4 changes: 2 additions & 2 deletions src/endpoints/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { z } from "zod";
import type { AnyZodObject, SetRequired } from "../types";
import type { AnyZodObject, OrderByDirection, SetRequired } from "../types";

export type FilterCondition = {
field: string;
Expand All @@ -13,7 +13,7 @@ export type ListFilters = {
page?: number;
per_page?: number;
order_by?: string;
order_by_direction?: "asc" | "desc";
order_by_direction?: OrderByDirection;
};
};

Expand Down
1 change: 1 addition & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export class OpenAPIHandler {
urlParams: urlParams,
raiseOnError: this.options?.raiseOnError,
raiseUnknownParameters: this.options?.raiseUnknownParameters,
passthroughErrors: this.options?.passthroughErrors,
}).execute(...params);
return this.wrapHandler(fn);
}
Expand Down
4 changes: 4 additions & 0 deletions src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ export class OpenAPIRoute<HandleArgs extends Array<object> = any> {
try {
resp = await this.handle(...args);
} catch (rawError) {
if (this.params?.passthroughErrors) {
throw rawError;
}

const e = this.handleError(rawError) ?? rawError;

if (this.params?.raiseOnError) {
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { ZodObject, ZodPipe, ZodType, z } from "zod";
// Type alias for compatibility with Zod v4
export type AnyZodObject = ZodObject<any, any>;

export type OrderByDirection = "asc" | "desc";

export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};

export type IsEqual<A, B> = (<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2 ? true : false;
Expand Down Expand Up @@ -73,12 +75,14 @@ export interface RouterOptions {
generateOperationIds?: boolean;
openapiVersion?: "3" | "3.1";
raiseOnError?: boolean;
passthroughErrors?: boolean;
}

export interface RouteOptions {
router: any;
raiseUnknownParameters: boolean;
raiseOnError?: boolean;
passthroughErrors?: boolean;
route: string;
urlParams: Array<string>;
}
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/d1-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
D1UpdateEndpoint,
fromIttyRouter,
InputValidationException,
type OrderByDirection,
} from "../../src";

// User schema for testing - id is optional because it's auto-generated
Expand Down Expand Up @@ -736,7 +737,7 @@ describe("D1 ListEndpoint defaultOrderByDirection", () => {
dbName = "DB";
orderByFields = ["id", "name"];
defaultOrderBy = "name";
defaultOrderByDirection: "asc" | "desc" = "desc";
defaultOrderByDirection: OrderByDirection = "desc";
}

const router = fromIttyRouter(AutoRouter({ base: "/api" }), { base: "/api" });
Expand Down
Loading
Loading