Skip to content

Commit e8631c9

Browse files
committed
feat(router): enhance route handling with named routes and parameter support
- Updated Router methods to return Route<HttpContext, Middleware, Handler> instead of void for better type safety. - Introduced support for named routes, allowing routes to be registered with a name and accessed via Router.route(name). - Enhanced parameter handling to support curly wrapped required and optional parameters in route definitions. - Updated all route retrieval methods to include support for named routes. - Added tests to validate the new functionality for named routes and parameter handling in the Express router.
1 parent 74d4179 commit e8631c9

10 files changed

Lines changed: 575 additions & 294 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ yarn add clear-router express
5050
- Controller handlers receive hydrated `this.body`, `this.query`, `this.params`, and `this.clearRequest`
5151
- `clearRequest` is passed as second handler argument for controller handlers
5252
- Route handlers can return response values directly across Express, Fastify, Hono, H3, and Koa
53+
- Laravel-style route parameters like `/books/{book}`, `/books/{book?}`, and `/books/{book:profile}`
54+
- Named routes with path generation via `Router.get(...).name(...)` and `Router.url(...)`
5355
- Optional decorated container binding for controller method arguments
5456
- Plugin API for registering container bindings from external packages
5557
- Supports TS 5.2+ standard decorators with explicit `@Bind(...)` tokens

docs/api.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,22 @@ console.log(routes);
421421
// ]
422422
```
423423

424+
### Named Routes and Curly Wrapped Parameters
425+
426+
Routes can be named by chaining `.name(...)` from a route registration call. Clear Router also accepts curly wrapped route parameters and converts them for each adapter:
427+
428+
```javascript
429+
Router.get('/books/{book}', handler).name('books.show');
430+
Router.get('/books/{book?}', handler).name('books.optional');
431+
Router.get('/books/{book:profile}', handler).name('books.profile');
432+
433+
Router.url('books.show', { book: 123 }); // /books/123
434+
Router.url('books.optional'); // /books
435+
Router.url('books.profile', { book: { profile: 'ada' } }); // /books/ada
436+
```
437+
438+
Named routes are available through `Router.route(name)` and `Router.allRoutes('name')`.
439+
424440
### apply(router)
425441

426442
Apply all registered routes to an Express Router instance.

src/Route.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { ClearRequest } from './ClearRequest'
44
import { Middleware as EMiddleware } from 'types/express'
55
import { Middleware as HMiddleware } from 'types/h3'
66

7+
export interface RouteParameter {
8+
name: string
9+
field?: string
10+
optional: boolean
11+
}
12+
713
export class Route<X = any, M = HMiddleware | EMiddleware, H = any> {
814
ctx!: X
915
body: RequestData = {}
@@ -13,6 +19,9 @@ export class Route<X = any, M = HMiddleware | EMiddleware, H = any> {
1319

1420
methods: HttpMethod[]
1521
path: string
22+
registrationPaths: string[]
23+
parameters: RouteParameter[]
24+
routeName?: string
1625
handler: H
1726
middlewares: M[]
1827
controllerName?: string
@@ -24,15 +33,59 @@ export class Route<X = any, M = HMiddleware | EMiddleware, H = any> {
2433
methods: HttpMethod[],
2534
path: string,
2635
handler: H,
27-
middlewares: M[] = []
36+
middlewares: M[] = [],
37+
options: {
38+
registrationPaths?: string[]
39+
parameters?: RouteParameter[]
40+
onName?: (name: string, route: Route<X, M, H>, previousName?: string) => void
41+
} = {}
2842
) {
2943
this.methods = methods
3044
this.path = path
45+
this.registrationPaths = options.registrationPaths || [path]
46+
this.parameters = options.parameters || []
3147
this.handler = handler
3248
this.middlewares = middlewares
3349
this.handlerType = Array.isArray(handler) ? 'controller' : 'function'
3450
this.middlewareCount = middlewares.length
3551
this.controllerName = Array.isArray(handler) ? handler[0]?.name : undefined
3652
this.actionName = Array.isArray(handler) ? handler[1] : typeof handler === 'function' ? handler.constructor.name ?? handler.name : undefined
53+
this.onName = options.onName
54+
}
55+
56+
private onName?: (name: string, route: Route<X, M, H>, previousName?: string) => void
57+
58+
name (name: string): this {
59+
const previousName = this.routeName
60+
this.routeName = name
61+
this.onName?.(name, this, previousName)
62+
63+
return this
64+
}
65+
66+
toPath (params: RequestData = {}): string {
67+
const path = this.path.replace(/\/?\{([^{}]+)\}/g, (segment, raw: string) => {
68+
const optional = raw.endsWith('?')
69+
const withoutOptional = optional ? raw.slice(0, -1) : raw
70+
const [rawName, rawField] = withoutOptional.split(':', 2)
71+
const name = rawName.trim()
72+
const field = rawField?.trim()
73+
const value = params[name]
74+
const resolved = field && value && typeof value === 'object'
75+
? value[field]
76+
: value
77+
78+
if (typeof resolved === 'undefined' || resolved === null || resolved === '') {
79+
if (optional) return ''
80+
81+
throw new Error(`Missing required route parameter: ${name}`)
82+
}
83+
84+
const prefix = segment.startsWith('/') ? '/' : ''
85+
86+
return `${prefix}${encodeURIComponent(String(resolved))}`
87+
})
88+
89+
return path || '/'
3790
}
38-
}
91+
}

src/core/router.ts

Lines changed: 116 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export abstract class CoreRouter {
132132
routes: [] as Array<Route<any, any, any>>,
133133
routesByPathMethod: {} as Record<string, Route<any, any, any>>,
134134
routesByMethod: {} as { [method in Uppercase<HttpMethod>]?: Array<Route<any, any, any>> },
135+
routesByName: {} as Record<string, Route<any, any, any>>,
135136
prefix: '',
136137
groupMiddlewares: [] as any[],
137138
globalMiddlewares: [] as any[],
@@ -156,6 +157,7 @@ export abstract class CoreRouter {
156157
'routes',
157158
'routesByPathMethod',
158159
'routesByMethod',
160+
'routesByName',
159161
'prefix',
160162
'groupMiddlewares',
161163
'globalMiddlewares',
@@ -341,6 +343,7 @@ export abstract class CoreRouter {
341343
static routes: Array<Route<any, any, any>> = []
342344
static routesByPathMethod: Record<string, Route<any, any, any>> = {}
343345
static routesByMethod: { [method in Uppercase<HttpMethod>]?: Array<Route<any, any, any>> } = {}
346+
static routesByName: Record<string, Route<any, any, any>> = {}
344347

345348
static prefix = ''
346349
static groupMiddlewares: any[] = []
@@ -432,6 +435,10 @@ export abstract class CoreRouter {
432435
this.routesByMethod = {}
433436
}
434437

438+
if (!this.routesByName || typeof this.routesByName !== 'object') {
439+
this.routesByName = {}
440+
}
441+
435442
if (typeof this.prefix !== 'string') {
436443
this.prefix = ''
437444
}
@@ -459,6 +466,68 @@ export abstract class CoreRouter {
459466
.join('/')
460467
}
461468

469+
protected static parseRouteParameters (path: string): Array<{
470+
name: string
471+
field?: string
472+
optional: boolean
473+
}> {
474+
const parameters: Array<{ name: string; field?: string; optional: boolean }> = []
475+
const seen = new Set<string>()
476+
const pattern = /\{([^{}]+)\}/g
477+
let match: RegExpExecArray | null
478+
479+
while ((match = pattern.exec(path)) !== null) {
480+
const raw = match[1].trim()
481+
const optional = raw.endsWith('?')
482+
const withoutOptional = optional ? raw.slice(0, -1) : raw
483+
const [name, field] = withoutOptional.split(':', 2).map(part => part.trim())
484+
485+
if (!name || seen.has(name)) continue
486+
487+
seen.add(name)
488+
parameters.push({
489+
name,
490+
field: field || undefined,
491+
optional,
492+
})
493+
}
494+
495+
return parameters
496+
}
497+
498+
protected static expandRoutePath (path: string): string[] {
499+
let paths = ['']
500+
const segments = this.normalizePath(path).split('/').filter(Boolean)
501+
502+
for (const segment of segments) {
503+
const match = segment.match(/^\{([^{}]+)\}$/)
504+
505+
if (!match) {
506+
paths = paths.map(current => `${current}/${segment}`)
507+
continue
508+
}
509+
510+
const raw = match[1].trim()
511+
const optional = raw.endsWith('?')
512+
const withoutOptional = optional ? raw.slice(0, -1) : raw
513+
const [rawName] = withoutOptional.split(':', 2)
514+
const name = rawName.trim()
515+
516+
if (!name) continue
517+
518+
const parameterSegment = `/:${name}`
519+
paths = optional
520+
? paths.flatMap(current => [current, `${current}${parameterSegment}`])
521+
: paths.map(current => `${current}${parameterSegment}`)
522+
}
523+
524+
return paths.map(path => path || '/')
525+
}
526+
527+
protected static routeRegistrationPaths (path: string): string[] {
528+
return this.expandRoutePath(path)
529+
}
530+
462531
/**
463532
* Configures the router with the given options, such as method override settings.
464533
*
@@ -573,7 +642,7 @@ export abstract class CoreRouter {
573642
path: string,
574643
handler: any,
575644
middlewares?: any[] | any
576-
): void {
645+
): Route<any, any, any> {
577646
this.ensureState()
578647

579648
const context = this.groupContext.getStore()
@@ -586,12 +655,25 @@ export abstract class CoreRouter {
586655
: undefined
587656

588657
const fullPath = this.normalizePath(`${activePrefix}/${path}`)
658+
const registrationPaths = this.routeRegistrationPaths(fullPath)
659+
const parameters = this.parseRouteParameters(fullPath)
589660

590661
const route = new Route(
591662
methods.includes('options') ? methods : methods.concat('options'),
592663
fullPath,
593664
handler,
594-
[...this.globalMiddlewares, ...activeGroupMiddlewares, ...(middlewares || [])]
665+
[...this.globalMiddlewares, ...activeGroupMiddlewares, ...(middlewares || [])],
666+
{
667+
registrationPaths,
668+
parameters,
669+
onName: (name, route, previousName) => {
670+
if (previousName && this.routesByName[previousName] === route) {
671+
delete this.routesByName[previousName]
672+
}
673+
674+
this.routesByName[name] = route
675+
},
676+
}
595677
)
596678

597679
if (
@@ -610,6 +692,8 @@ export abstract class CoreRouter {
610692
}
611693
this.routesByMethod[method].push(route)
612694
}
695+
696+
return route
613697
}
614698

615699
/**
@@ -670,8 +754,8 @@ export abstract class CoreRouter {
670754
* @param handler The handler function for the GET route.
671755
* @param middlewares Optional middlewares to apply to the GET route.
672756
*/
673-
static get (path: string, handler: any, middlewares?: any[] | any): void {
674-
this.add('get', path, handler, middlewares)
757+
static get (path: string, handler: any, middlewares?: any[] | any): Route<any, any, any> {
758+
return this.add('get', path, handler, middlewares)
675759
}
676760

677761
/**
@@ -682,8 +766,8 @@ export abstract class CoreRouter {
682766
* @param handler
683767
* @param middlewares
684768
*/
685-
static post (path: string, handler: any, middlewares?: any[] | any): void {
686-
this.add('post', path, handler, middlewares)
769+
static post (path: string, handler: any, middlewares?: any[] | any): Route<any, any, any> {
770+
return this.add('post', path, handler, middlewares)
687771
}
688772

689773
/**
@@ -694,8 +778,8 @@ export abstract class CoreRouter {
694778
* @param handler
695779
* @param middlewares
696780
*/
697-
static put (path: string, handler: any, middlewares?: any[] | any): void {
698-
this.add('put', path, handler, middlewares)
781+
static put (path: string, handler: any, middlewares?: any[] | any): Route<any, any, any> {
782+
return this.add('put', path, handler, middlewares)
699783
}
700784

701785
/**
@@ -706,8 +790,8 @@ export abstract class CoreRouter {
706790
* @param handler
707791
* @param middlewares
708792
*/
709-
static delete (path: string, handler: any, middlewares?: any[] | any): void {
710-
this.add('delete', path, handler, middlewares)
793+
static delete (path: string, handler: any, middlewares?: any[] | any): Route<any, any, any> {
794+
return this.add('delete', path, handler, middlewares)
711795
}
712796

713797
/**
@@ -718,8 +802,8 @@ export abstract class CoreRouter {
718802
* @param handler
719803
* @param middlewares
720804
*/
721-
static patch (path: string, handler: any, middlewares?: any[] | any): void {
722-
this.add('patch', path, handler, middlewares)
805+
static patch (path: string, handler: any, middlewares?: any[] | any): Route<any, any, any> {
806+
return this.add('patch', path, handler, middlewares)
723807
}
724808

725809
/**
@@ -730,8 +814,8 @@ export abstract class CoreRouter {
730814
* @param handler
731815
* @param middlewares
732816
*/
733-
static options (path: string, handler: any, middlewares?: any[] | any): void {
734-
this.add('options', path, handler, middlewares)
817+
static options (path: string, handler: any, middlewares?: any[] | any): Route<any, any, any> {
818+
return this.add('options', path, handler, middlewares)
735819
}
736820

737821
/**
@@ -742,8 +826,8 @@ export abstract class CoreRouter {
742826
* @param handler
743827
* @param middlewares
744828
*/
745-
static head (path: string, handler: any, middlewares?: any[] | any): void {
746-
this.add('head', path, handler, middlewares)
829+
static head (path: string, handler: any, middlewares?: any[] | any): Route<any, any, any> {
830+
return this.add('head', path, handler, middlewares)
747831
}
748832

749833
/**
@@ -814,7 +898,8 @@ export abstract class CoreRouter {
814898
* @param type - 'method' to get routes organized by method
815899
*/
816900
static allRoutes (type: 'method'): { [method in Uppercase<HttpMethod>]?: Array<Route<any, any, any>> }
817-
static allRoutes (type?: 'method' | 'path'):
901+
static allRoutes (type: 'name'): Record<string, Route<any, any, any>>
902+
static allRoutes (type?: 'method' | 'path' | 'name'):
818903
Array<Route<any, any, any>> |
819904
Record<string, Route<any, any, any>> |
820905
Record<string, Array<Route<any, any, any>>> {
@@ -828,9 +913,23 @@ export abstract class CoreRouter {
828913
return this.routesByPathMethod
829914
}
830915

916+
if (type === 'name') {
917+
return this.routesByName
918+
}
919+
831920
return this.routes.filter((e: Route<any, any, any>) => e.methods.length > 1 || e.methods[0] !== 'options')
832921
}
833922

923+
static route (name: string): Route<any, any, any> | undefined {
924+
this.ensureState()
925+
926+
return this.routesByName[name]
927+
}
928+
929+
static url (name: string, params?: Record<string, any>): string | undefined {
930+
return this.route(name)?.toPath(params)
931+
}
932+
834933
protected static resolveHandler (route: Route<any, any, any>): {
835934
handlerFunction: ((ctx: any, req: CoreRequest) => any | Promise<any>) | null
836935
instance: Controller<any> | null

0 commit comments

Comments
 (0)