From 67ecfd25583846c68cacac799248efbf29df1a78 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas Date: Thu, 26 Feb 2026 20:19:44 +0000 Subject: [PATCH 1/3] feat: export OrderByDirection type alias for "asc" | "desc" --- .changeset/add-order-by-direction-type.md | 5 +++++ docs/endpoints/auto/base.md | 2 +- src/endpoints/d1/base.ts | 7 ++++--- src/endpoints/list.ts | 4 ++-- src/endpoints/types.ts | 4 ++-- src/types.ts | 2 ++ tests/integration/d1-endpoints.test.ts | 3 ++- 7 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 .changeset/add-order-by-direction-type.md diff --git a/.changeset/add-order-by-direction-type.md b/.changeset/add-order-by-direction-type.md new file mode 100644 index 0000000..7bda0b6 --- /dev/null +++ b/.changeset/add-order-by-direction-type.md @@ -0,0 +1,5 @@ +--- +"chanfana": patch +--- + +Export `OrderByDirection` type alias (`"asc" | "desc"`) so consumers can import it directly instead of inlining literal unions diff --git a/docs/endpoints/auto/base.md b/docs/endpoints/auto/base.md index db0412c..621f348 100644 --- a/docs/endpoints/auto/base.md +++ b/docs/endpoints/auto/base.md @@ -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. diff --git a/src/endpoints/d1/base.ts b/src/endpoints/d1/base.ts index fb5e3ea..a34e013 100644 --- a/src/endpoints/d1/base.ts +++ b/src/endpoints/d1/base.ts @@ -1,4 +1,5 @@ import { ApiException, InputValidationException } from "../../exceptions"; +import type { OrderByDirection } from "../../types"; import type { FilterCondition, Filters, Logger } from "../types"; /** @@ -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"; } /** @@ -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}`; } diff --git a/src/endpoints/list.ts b/src/endpoints/list.ts index 011eabd..f46ee3a 100644 --- a/src/endpoints/list.ts +++ b/src/endpoints/list.ts @@ -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, @@ -29,7 +29,7 @@ export class ListEndpoint = Array> 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( diff --git a/src/endpoints/types.ts b/src/endpoints/types.ts index 103e2e2..59764c5 100644 --- a/src/endpoints/types.ts +++ b/src/endpoints/types.ts @@ -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; @@ -13,7 +13,7 @@ export type ListFilters = { page?: number; per_page?: number; order_by?: string; - order_by_direction?: "asc" | "desc"; + order_by_direction?: OrderByDirection; }; }; diff --git a/src/types.ts b/src/types.ts index 9e79a0d..faec2d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,8 @@ import type { ZodObject, ZodPipe, ZodType, z } from "zod"; // Type alias for compatibility with Zod v4 export type AnyZodObject = ZodObject; +export type OrderByDirection = "asc" | "desc"; + export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; export type IsEqual = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; diff --git a/tests/integration/d1-endpoints.test.ts b/tests/integration/d1-endpoints.test.ts index 5a6a627..a0de6ae 100644 --- a/tests/integration/d1-endpoints.test.ts +++ b/tests/integration/d1-endpoints.test.ts @@ -10,6 +10,7 @@ import { D1UpdateEndpoint, fromIttyRouter, InputValidationException, + type OrderByDirection, } from "../../src"; // User schema for testing - id is optional because it's auto-generated @@ -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" }); From beb84f914ee047ddcf2f102e72774b976af9cfcf Mon Sep 17 00:00:00 2001 From: Gabriel Massadas Date: Thu, 26 Feb 2026 20:27:24 +0000 Subject: [PATCH 2/3] feat: add passthroughErrors option to bypass chanfana error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a router-level passthroughErrors flag that makes errors propagate as raw exceptions to the framework's error handler (e.g. Hono's onError) without any chanfana processing — no handleError(), no formatChanfanaError(), no HTTPException wrapping. --- .changeset/add-passthrough-errors.md | 5 + docs/error-handling.md | 61 +++++++ docs/examples-and-recipes.md | 29 +++ docs/openapi-configuration-customization.md | 36 ++++ docs/router-adapters.md | 8 + src/adapters/hono.ts | 2 + src/openapi.ts | 1 + src/route.ts | 4 + src/types.ts | 2 + tests/integration/passthroughErrors.test.ts | 188 ++++++++++++++++++++ 10 files changed, 336 insertions(+) create mode 100644 .changeset/add-passthrough-errors.md create mode 100644 tests/integration/passthroughErrors.test.ts diff --git a/.changeset/add-passthrough-errors.md b/.changeset/add-passthrough-errors.md new file mode 100644 index 0000000..adad088 --- /dev/null +++ b/.changeset/add-passthrough-errors.md @@ -0,0 +1,5 @@ +--- +"chanfana": patch +--- + +Add `passthroughErrors` option to bypass chanfana's error handling and let errors propagate raw to the framework's error handler diff --git a/docs/error-handling.md b/docs/error-handling.md index 99e240a..8d640d0 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -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: @@ -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. diff --git a/docs/examples-and-recipes.md b/docs/examples-and-recipes.md index 3c72c0a..3ba7944 100644 --- a/docs/examples-and-recipes.md +++ b/docs/examples-and-recipes.md @@ -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: diff --git a/docs/openapi-configuration-customization.md b/docs/openapi-configuration-customization.md index fbbdbf2..42f667e 100644 --- a/docs/openapi-configuration-customization.md +++ b/docs/openapi-configuration-customization.md @@ -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. diff --git a/docs/router-adapters.md b/docs/router-adapters.md index aacdb8a..b90d7df 100644 --- a/docs/router-adapters.md +++ b/docs/router-adapters.md @@ -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` diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts index bd85bab..b6e12ba 100644 --- a/src/adapters/hono.ts +++ b/src/adapters/hono.ts @@ -107,6 +107,8 @@ export class HonoOpenAPIHandler extends OpenAPIHandler { * error response format via HTTPException.getResponse(). */ protected wrapHandler(handler: (...args: any[]) => Promise): (...args: any[]) => Promise { + if (this.options?.passthroughErrors) return handler; + return async (...args: any[]) => { try { return await handler(...args); diff --git a/src/openapi.ts b/src/openapi.ts index fc3e084..3bc2341 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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); } diff --git a/src/route.ts b/src/route.ts index 6f0bb78..f94f355 100644 --- a/src/route.ts +++ b/src/route.ts @@ -192,6 +192,10 @@ export class OpenAPIRoute = 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) { diff --git a/src/types.ts b/src/types.ts index faec2d8..3e7a722 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,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; } diff --git a/tests/integration/passthroughErrors.test.ts b/tests/integration/passthroughErrors.test.ts new file mode 100644 index 0000000..99b3fbb --- /dev/null +++ b/tests/integration/passthroughErrors.test.ts @@ -0,0 +1,188 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ApiException, fromHono, MultiException, NotFoundException, OpenAPIRoute } from "../../src"; + +class EndpointThatThrowsNotFound extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, + }; + + async handle() { + throw new NotFoundException("Item not found"); + } +} + +class EndpointWithValidation extends OpenAPIRoute { + schema = { + request: { + body: { + content: { + "application/json": { + schema: z.object({ + name: z.string(), + }), + }, + }, + }, + }, + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, + }; + + async handle() { + await this.getValidatedData(); + return { success: true }; + } +} + +class EndpointThatThrowsPlainError extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, + }; + + async handle() { + throw new Error("Unexpected failure"); + } +} + +describe("passthroughErrors", () => { + it("ApiException propagates as-is to onError (not HTTPException)", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + return c.json({ error: "caught" }, 500); + }); + + const router = fromHono(app, { passthroughErrors: true }); + router.get("/items/:id", EndpointThatThrowsNotFound); + + const request = await router.fetch(new Request("http://localhost/items/123")); + const resp = await request.json(); + + // Error should be the raw NotFoundException, NOT an HTTPException + expect(caughtError).toBeInstanceOf(NotFoundException); + expect(caughtError).toBeInstanceOf(ApiException); + expect(caughtError).not.toBeInstanceOf(HTTPException); + expect((caughtError as NotFoundException).message).toEqual("Item not found"); + expect((caughtError as NotFoundException).status).toEqual(404); + expect((caughtError as NotFoundException).code).toEqual(7002); + + // Response comes from the onError handler, not from chanfana + expect(request.status).toEqual(500); + expect((resp as any).error).toEqual("caught"); + }); + + it("validation error propagates as raw MultiException to onError (not HTTPException)", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + return c.json({ error: "validation caught" }, 422); + }); + + const router = fromHono(app, { passthroughErrors: true }); + router.post("/items", EndpointWithValidation); + + const request = await router.fetch( + new Request("http://localhost/items", { + method: "POST", + body: JSON.stringify({ name: 123 }), + headers: { "Content-Type": "application/json" }, + }), + ); + + const resp = await request.json(); + + // Error should be a raw MultiException (chanfana's validation wrapper), not HTTPException + expect(caughtError).toBeInstanceOf(MultiException); + expect(caughtError).toBeInstanceOf(ApiException); + expect(caughtError).not.toBeInstanceOf(HTTPException); + + // Response comes from the onError handler, not from chanfana + expect(request.status).toEqual(422); + expect((resp as any).error).toEqual("validation caught"); + }); + + it("plain errors propagate as-is to onError", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + return c.json({ error: "caught" }, 500); + }); + + const router = fromHono(app, { passthroughErrors: true }); + router.get("/crash", EndpointThatThrowsPlainError); + + const request = await router.fetch(new Request("http://localhost/crash")); + const resp = await request.json(); + + expect(caughtError).toBeInstanceOf(Error); + expect(caughtError).not.toBeInstanceOf(HTTPException); + expect((caughtError as Error).message).toEqual("Unexpected failure"); + + expect(request.status).toEqual(500); + expect((resp as any).error).toEqual("caught"); + }); + + it("without the flag, errors still arrive as HTTPException (regression)", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + if (err instanceof HTTPException) { + return err.getResponse(); + } + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app); + router.get("/items/:id", EndpointThatThrowsNotFound); + + const request = await router.fetch(new Request("http://localhost/items/123")); + const resp = await request.json(); + + // Default behavior: error wrapped as HTTPException + expect(caughtError).toBeInstanceOf(HTTPException); + expect((caughtError as HTTPException).status).toEqual(404); + + // Response has chanfana's standard error format + expect(request.status).toEqual(404); + expect((resp as any).success).toEqual(false); + expect((resp as any).errors[0].code).toEqual(7002); + expect((resp as any).errors[0].message).toEqual("Item not found"); + }); +}); From d7d98c3d02af0652d49bbcb71cb1447724b84e17 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas Date: Thu, 26 Feb 2026 20:29:04 +0000 Subject: [PATCH 3/3] docs: add usage example to passthroughErrors changeset --- .changeset/add-passthrough-errors.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.changeset/add-passthrough-errors.md b/.changeset/add-passthrough-errors.md index adad088..56eb33c 100644 --- a/.changeset/add-passthrough-errors.md +++ b/.changeset/add-passthrough-errors.md @@ -3,3 +3,24 @@ --- 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 }); +```