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
10 changes: 10 additions & 0 deletions .changeset/zod-openapi-basepath-onerror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@hono/zod-openapi': patch
---

fix(zod-openapi): preserve `onError()`/`notFound()` set after `basePath()`

`OpenAPIHono.basePath()` used to rebuild the instance by spreading the plain `Hono` clone returned by `super.basePath()`.
That spread copied the clone's arrow-function instance fields (`onError`, `notFound`, `request`, `fetch`),
which stay bound to the discarded clone — so calling `.onError()` (or `.notFound()`) *after* `.basePath()` mutated the throwaway clone instead of the returned app and silently had no effect (the parent's handler ran instead).
`basePath()` now transplants only the routing state onto a properly constructed `OpenAPIHono`, keeping its own correctly-bound methods. Fixes #2021.
2 changes: 1 addition & 1 deletion packages/zod-openapi/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"count": 2
},
"@typescript-eslint/no-unsafe-argument": {
"count": 13
"count": 12
},
"@typescript-eslint/no-unsafe-assignment": {
"count": 1
Expand Down
51 changes: 51 additions & 0 deletions packages/zod-openapi/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1370,6 +1370,57 @@ describe('basePath()', () => {
const client = hc<typeof routes>('http://localhost/')
expect(client.api.doc31.$url().pathname).toBe('/api/doc31')
})

const errorRoute = createRoute({
method: 'get',
path: '/error',
responses: {
200: {
description: 'ok',
},
},
})
const throwHandler = () => {
throw new Error('boom')
}

it('Should fire onError() registered after basePath()', async () => {
const parent = new OpenAPIHono()
const sub = new OpenAPIHono().basePath('/api')
sub.openapi(errorRoute, throwHandler)
sub.onError((err, c) => c.text('caught: ' + err.message, 500))
parent.route('/', sub)

const res = await parent.request('/api/error')
expect(res.status).toBe(500)
expect(await res.text()).toBe('caught: boom')
})

it('Should fire onError() registered before basePath()', async () => {
const parent = new OpenAPIHono()
const base = new OpenAPIHono()
base.onError((err, c) => c.text('caught: ' + err.message, 500))
const sub = base.basePath('/api')
sub.openapi(errorRoute, throwHandler)
parent.route('/', sub)

const res = await parent.request('/api/error')
expect(res.status).toBe(500)
expect(await res.text()).toBe('caught: boom')
})

// Note: `route()` does not propagate `notFoundHandler` (only `errorHandler`),
// so this is a same-instance contract test that `notFound()` set after
// `basePath()` targets the returned instance -- not a regression guard for the
// binding fix (it passes with or without the fix).
it('notFound() set after basePath() targets the returned instance', async () => {
const app = new OpenAPIHono().basePath('/api')
app.notFound((c) => c.text('custom not found', 404))

const res = await app.request('/api/missing')
expect(res.status).toBe(404)
expect(await res.text()).toBe('custom not found')
})
})

describe('onError() and onNotFound()', () => {
Expand Down
16 changes: 15 additions & 1 deletion packages/zod-openapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,21 @@ export class OpenAPIHono<
override basePath<SubPath extends string>(
path: SubPath
): OpenAPIHono<E, S, MergePath<BasePath, SubPath>> {
return new OpenAPIHono({ ...(super.basePath(path) as any), defaultHook: this.defaultHook })
const cloned = super.basePath(path)
const newApp = new OpenAPIHono<E, S, MergePath<BasePath, SubPath>>({
defaultHook: this.defaultHook,
})
newApp.router = cloned.router
newApp.routes = cloned.routes
const { getPath } = cloned

const clonedInternal = cloned as unknown as { errorHandler: ErrorHandler<E>; _basePath: string }
Object.assign(newApp, {
getPath,
errorHandler: clonedInternal.errorHandler,
_basePath: clonedInternal._basePath,
})
return newApp
}

// Type overrides to return OpenAPIHono instead of Hono
Expand Down
Loading