feat(controller): add support for bypassing global prefix in controller metadata

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-01 18:31:50 +08:00
parent 0c84619e88
commit 2da5204ffd
5 changed files with 99 additions and 31 deletions

View File

@@ -3,7 +3,7 @@ import type { Context } from 'hono'
import { StaticWebService } from './static-web.service'
@Controller('static/web')
@Controller({ bypassGlobalPrefix: true })
export class StaticWebController {
constructor(private readonly staticWebService: StaticWebService) {}

View File

@@ -522,9 +522,10 @@ export class HonoHttpApplication {
private normalizeMiddlewareDefinition(definition: MiddlewareDefinition): MiddlewareDefinition {
const priority = definition.priority ?? 0
const path = definition.path ?? '/*'
return {
handler: definition.handler,
path: definition.path,
path,
priority,
}
}
@@ -545,15 +546,17 @@ export class HonoHttpApplication {
for (const definition of normalized) {
this.globalEnhancers.middlewares.push(definition)
const { path } = definition
let { path } = definition
if (path == null) {
path = '/*'
definition.path = path
}
const handlerName = definition.handler.constructor.name || 'AnonymousMiddleware'
const middlewareFn = async (context: Context, next: Next) => {
return await definition.handler.use(context, next)
}
if (path == null) {
this.app.use(middlewareFn)
} else if (Array.isArray(path)) {
if (Array.isArray(path)) {
for (const entry of path) {
this.app.use(entry as any, middlewareFn)
}
@@ -561,7 +564,7 @@ export class HonoHttpApplication {
this.app.use(path as any, middlewareFn)
}
this.middlewareLogger.info(
this.middlewareLogger.verbose(
`Registered middleware ${handlerName} on ${this.describeMiddlewarePath(path)}`,
colors.green(`+${performance.now().toFixed(2)}ms`),
)
@@ -868,12 +871,12 @@ export class HonoHttpApplication {
private registerController(controller: Constructor): void {
const controllerInstance = this.getProviderInstance(controller)
const { prefix } = getControllerMetadata(controller)
const controllerMetadata = getControllerMetadata(controller)
const routes = getRoutesMetadata(controller)
for (const route of routes) {
const method = route.method.toUpperCase() as HTTPMethod
const fullPath = this.buildPath(prefix, route.path)
const fullPath = this.buildPath(controllerMetadata, route.path)
this.app.on(method, fullPath, async (context: Context) => {
return await HttpContext.run(context, async () => {
@@ -915,19 +918,31 @@ export class HonoHttpApplication {
}
}
private buildPath(prefix: string, routePath: string): string {
private buildPath(controller: ReturnType<typeof getControllerMetadata>, routePath: string): string {
const globalPrefix = this.options.globalPrefix ?? ''
const pieces = [globalPrefix, prefix, routePath]
const pieces = [routePath]
if (controller.prefix) {
pieces.unshift(controller.prefix)
}
if (!controller.bypassGlobalPrefix && globalPrefix) {
pieces.unshift(globalPrefix)
}
if (pieces.length === 0) {
return '/'
}
const normalized = pieces
.map((segment) => segment?.trim())
.filter(Boolean)
.map((segment) => (segment!.startsWith('/') ? segment : `/${segment}`))
const normalized = pieces.join('').replaceAll(/[\\/]+/g, '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
return normalized.slice(0, -1)
const joined = normalized.join('').replaceAll(/[\\/]+/g, '/')
if (joined.length > 1 && joined.endsWith('/')) {
return joined.slice(0, -1)
}
return normalized || '/'
return joined || '/'
}
private async executeGuards(

View File

@@ -5,24 +5,43 @@ import type { Constructor } from '../interfaces'
export interface ControllerMetadata {
prefix: string
bypassGlobalPrefix: boolean
}
export function Controller(prefix = ''): ClassDecorator {
export interface ControllerOptions {
prefix?: string
bypassGlobalPrefix?: boolean
}
function normalizeControllerOptions(prefixOrOptions: string | ControllerOptions | undefined): ControllerMetadata {
if (typeof prefixOrOptions === 'string' || prefixOrOptions === undefined) {
return {
prefix: prefixOrOptions ?? '',
bypassGlobalPrefix: false,
}
}
return {
prefix: prefixOrOptions.prefix ?? '',
bypassGlobalPrefix: prefixOrOptions.bypassGlobalPrefix ?? false,
}
}
export function Controller(prefixOrOptions: string | ControllerOptions = ''): ClassDecorator {
const metadata = normalizeControllerOptions(prefixOrOptions)
return (target) => {
Reflect.defineMetadata(
CONTROLLER_METADATA,
{
prefix,
} satisfies ControllerMetadata,
target as unknown as Constructor,
)
Reflect.defineMetadata(CONTROLLER_METADATA, metadata satisfies ControllerMetadata, target as unknown as Constructor)
injectable()(target as unknown as Constructor)
}
}
export function getControllerMetadata(target: Constructor): ControllerMetadata {
return (Reflect.getMetadata(CONTROLLER_METADATA, target) || {
prefix: '',
}) as ControllerMetadata
const metadata = Reflect.getMetadata(CONTROLLER_METADATA, target) as Partial<ControllerMetadata> | undefined
return {
prefix: metadata?.prefix ?? '',
bypassGlobalPrefix: metadata?.bypassGlobalPrefix ?? false,
}
}

View File

@@ -525,6 +525,18 @@ class FactoryController {
})
class FactoryModule {}
@injectable()
@Controller({ prefix: 'static', bypassGlobalPrefix: true })
class StaticController {
@Get('/asset')
asset() {
return 'static-asset'
}
}
@Module({ controllers: [StaticController] })
class StaticModule {}
const lifecycleEvents: string[] = []
@injectable()
@@ -705,6 +717,21 @@ describe('HonoHttpApplication end-to-end', () => {
expect(callOrder).toContain('zod-body:BodyDto')
})
it('supports bypassing global prefix for specific controllers', async () => {
const app = await createApplication(StaticModule, { globalPrefix: '/api' })
const hono = app.getInstance()
const fetcher = (request: Request) => Promise.resolve(hono.fetch(request))
const success = await fetcher(createRequest('/static/asset'))
expect(success.status).toBe(200)
expect(await success.text()).toBe('static-asset')
const missing = await fetcher(createRequest('/api/static/asset'))
expect(missing.status).toBe(404)
await app.close('bypass-global-prefix')
})
it('surfaces validation errors for DTO payloads', async () => {
const response = await fetcher(
createRequest('/api/demo/double/5', {
@@ -1003,7 +1030,7 @@ describe('HonoHttpApplication internals', () => {
const ensureResponse = (
app as unknown as {
ensureResponse: (ctx: Context, payload: unknown) => Response | unknown
ensureResponse: (ctx: Context, payload?: unknown) => Response | unknown
}
).ensureResponse.bind(app)
@@ -1023,7 +1050,7 @@ describe('HonoHttpApplication internals', () => {
const ensureResponse = (
app as unknown as {
ensureResponse: (ctx: Context, payload: unknown) => Response | unknown
ensureResponse: (ctx: Context, payload?: unknown) => Response | unknown
}
).ensureResponse.bind(app)
@@ -1129,7 +1156,7 @@ describe('HonoHttpApplication internals', () => {
it('extracts middleware metadata and lifecycle targets across variants', async () => {
const app = await createApplication(FactoryModule)
const internals = app as unknown as {
extractMiddlewareLifecycleTarget: (value: unknown) => unknown
extractMiddlewareLifecycleTarget: (value?: unknown) => unknown
extractMiddlewareMetadata: (source?: Constructor | Record<string, unknown>) => Record<string, unknown>
resolveMiddlewareDefinition: (value: unknown, source?: Constructor | Record<string, unknown>) => unknown
getEnhancerType: (token: unknown) => unknown
@@ -1368,11 +1395,11 @@ describe('HonoHttpApplication internals', () => {
const app = await createApplication(FactoryModule)
const buildPath = (
app as unknown as {
buildPath: (prefix: string, routePath: string) => string
buildPath: (controller: { prefix: string; bypassGlobalPrefix: boolean }, routePath: string) => string
}
).buildPath.bind(app)
expect(buildPath('', '')).toBe('/')
expect(buildPath({ prefix: '', bypassGlobalPrefix: false }, '')).toBe('/')
await app.close('normalize-paths')
})

7
builder.config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"storage": {
"provider": "local",
"basePath": "./apps/web/public/photos",
"baseUrl": "/photos"
}
}