mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(controller): add support for bypassing global prefix in controller metadata
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
7
builder.config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"storage": {
|
||||
"provider": "local",
|
||||
"basePath": "./apps/web/public/photos",
|
||||
"baseUrl": "/photos"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user