@strav/http 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/http/index.ts +3 -1
- package/src/http/route_helper.ts +134 -0
- package/src/http/router.ts +149 -10
package/package.json
CHANGED
package/src/http/index.ts
CHANGED
|
@@ -8,11 +8,13 @@ export { compose } from './middleware.ts'
|
|
|
8
8
|
export { serializeCookie, parseCookies, withCookie, clearCookie } from './cookie.ts'
|
|
9
9
|
export { rateLimit, MemoryStore } from './rate_limit.ts'
|
|
10
10
|
export { Resource } from './resource.ts'
|
|
11
|
+
export { route, routeUrl } from './route_helper.ts'
|
|
11
12
|
export type { Handler, Middleware, Next } from './middleware.ts'
|
|
12
|
-
export type { GroupOptions, WebSocketHandlers, WebSocketData } from './router.ts'
|
|
13
|
+
export type { GroupOptions, WebSocketHandlers, WebSocketData, RouteDefinition } from './router.ts'
|
|
13
14
|
export type { CookieOptions } from './cookie.ts'
|
|
14
15
|
export type { CorsOptions } from './cors.ts'
|
|
15
16
|
export type { RateLimitOptions, RateLimitStore, RateLimitInfo } from './rate_limit.ts'
|
|
17
|
+
export type { RouteOptions } from './route_helper.ts'
|
|
16
18
|
|
|
17
19
|
if (!app.has(Router)) app.singleton(Router)
|
|
18
20
|
export const router = app.resolve(Router)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { router } from './index.ts'
|
|
2
|
+
import type { RouteDefinition } from './router.ts'
|
|
3
|
+
|
|
4
|
+
export interface RouteOptions extends Omit<RequestInit, 'body'> {
|
|
5
|
+
params?: Record<string, any>
|
|
6
|
+
body?: any
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Invoke a named route with automatic method detection and smart defaults.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Simple POST with JSON body
|
|
14
|
+
* await route('auth.register', {
|
|
15
|
+
* name: 'John',
|
|
16
|
+
* email: 'john@example.com',
|
|
17
|
+
* password: 'secret'
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* // GET with URL parameters
|
|
21
|
+
* await route('users.show', { params: { id: 123 } })
|
|
22
|
+
*
|
|
23
|
+
* // Custom headers and options
|
|
24
|
+
* await route('api.upload', {
|
|
25
|
+
* body: formData,
|
|
26
|
+
* headers: { 'X-Custom-Header': 'value' }
|
|
27
|
+
* })
|
|
28
|
+
*
|
|
29
|
+
* // Override detected method
|
|
30
|
+
* await route('users.index', { method: 'HEAD' })
|
|
31
|
+
*/
|
|
32
|
+
export async function route(
|
|
33
|
+
name: string,
|
|
34
|
+
data?: any,
|
|
35
|
+
options?: RouteOptions
|
|
36
|
+
): Promise<Response> {
|
|
37
|
+
const routeDef = router.getRouteByName(name)
|
|
38
|
+
if (!routeDef) {
|
|
39
|
+
throw new Error(`Route '${name}' not found`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Determine if data is the body or options
|
|
43
|
+
let body: any
|
|
44
|
+
let opts: RouteOptions = {}
|
|
45
|
+
|
|
46
|
+
if (data !== undefined) {
|
|
47
|
+
// If data has params, body, or any RequestInit properties, treat it as options
|
|
48
|
+
if (
|
|
49
|
+
typeof data === 'object' &&
|
|
50
|
+
!Array.isArray(data) &&
|
|
51
|
+
!(data instanceof FormData) &&
|
|
52
|
+
!(data instanceof Blob) &&
|
|
53
|
+
!(data instanceof ArrayBuffer) &&
|
|
54
|
+
!(data instanceof URLSearchParams) &&
|
|
55
|
+
('params' in data || 'body' in data || 'headers' in data || 'cache' in data ||
|
|
56
|
+
'credentials' in data || 'mode' in data || 'redirect' in data || 'referrer' in data)
|
|
57
|
+
) {
|
|
58
|
+
opts = data
|
|
59
|
+
body = opts.body
|
|
60
|
+
} else {
|
|
61
|
+
// Otherwise, treat data as the body
|
|
62
|
+
body = data
|
|
63
|
+
opts = options || {}
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
opts = options || {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Generate URL with parameters
|
|
70
|
+
const generatedUrl = router.generateUrl(name, opts.params)
|
|
71
|
+
|
|
72
|
+
// Determine method from route definition
|
|
73
|
+
const method = opts.method || routeDef.method
|
|
74
|
+
|
|
75
|
+
// Build headers with smart defaults
|
|
76
|
+
const headers = new Headers(opts.headers)
|
|
77
|
+
|
|
78
|
+
// Set default Accept header if not provided
|
|
79
|
+
if (!headers.has('Accept')) {
|
|
80
|
+
headers.set('Accept', 'application/json')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Handle body and Content-Type
|
|
84
|
+
let requestBody: string | FormData | Blob | ArrayBuffer | URLSearchParams | undefined
|
|
85
|
+
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
86
|
+
if (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || body instanceof URLSearchParams) {
|
|
87
|
+
// Let fetch set the Content-Type for FormData, or use the existing type for Blob/ArrayBuffer
|
|
88
|
+
requestBody = body
|
|
89
|
+
} else if (typeof body === 'object') {
|
|
90
|
+
// JSON body
|
|
91
|
+
if (!headers.has('Content-Type')) {
|
|
92
|
+
headers.set('Content-Type', 'application/json')
|
|
93
|
+
}
|
|
94
|
+
requestBody = JSON.stringify(body)
|
|
95
|
+
} else {
|
|
96
|
+
// String or other primitive
|
|
97
|
+
requestBody = String(body)
|
|
98
|
+
if (!headers.has('Content-Type')) {
|
|
99
|
+
headers.set('Content-Type', 'text/plain')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Set default credentials if not provided
|
|
105
|
+
const credentials = opts.credentials || 'same-origin'
|
|
106
|
+
|
|
107
|
+
// Build final fetch options
|
|
108
|
+
const fetchOptions: RequestInit = {
|
|
109
|
+
...opts,
|
|
110
|
+
method,
|
|
111
|
+
headers,
|
|
112
|
+
credentials,
|
|
113
|
+
...(requestBody !== undefined && { body: requestBody })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Remove our custom properties
|
|
117
|
+
delete (fetchOptions as any).params
|
|
118
|
+
|
|
119
|
+
return fetch(generatedUrl, fetchOptions)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Generate a URL for a named route with optional parameters.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* const profileUrl = routeUrl('users.profile', { id: 456 })
|
|
127
|
+
* // Returns '/users/456'
|
|
128
|
+
*
|
|
129
|
+
* const searchUrl = routeUrl('api.search', { q: 'test', page: 2 })
|
|
130
|
+
* // Returns '/api/search?q=test&page=2'
|
|
131
|
+
*/
|
|
132
|
+
export function routeUrl(name: string, params?: Record<string, any>): string {
|
|
133
|
+
return router.generateUrl(name, params)
|
|
134
|
+
}
|
package/src/http/router.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type ControllerAction = [Constructor, string]
|
|
|
19
19
|
/** Accepted as a route handler: a function or a `[Controller, 'method']` tuple. */
|
|
20
20
|
export type HandlerInput = Handler | ControllerAction
|
|
21
21
|
|
|
22
|
-
interface RouteDefinition {
|
|
22
|
+
export interface RouteDefinition {
|
|
23
23
|
method: string
|
|
24
24
|
pattern: string
|
|
25
25
|
regex: RegExp
|
|
@@ -64,6 +64,8 @@ interface GroupState {
|
|
|
64
64
|
middleware: Middleware[]
|
|
65
65
|
subdomain?: string
|
|
66
66
|
subdomainParamName?: string
|
|
67
|
+
alias?: string
|
|
68
|
+
ref?: GroupRef // Reference to the GroupRef for deferred alias assignment
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
// ---------------------------------------------------------------------------
|
|
@@ -102,13 +104,69 @@ function parseSubdomain(pattern: string): { value: string; paramName?: string }
|
|
|
102
104
|
// ---------------------------------------------------------------------------
|
|
103
105
|
|
|
104
106
|
class RouteRef {
|
|
105
|
-
|
|
107
|
+
private groupRefs: (GroupRef | undefined)[]
|
|
108
|
+
private routeName?: string
|
|
109
|
+
|
|
110
|
+
constructor(private route: RouteDefinition, private router: Router) {
|
|
111
|
+
// Capture references to the GroupRefs from the current stack
|
|
112
|
+
this.groupRefs = router.groupStack.map(state => state.ref)
|
|
113
|
+
}
|
|
106
114
|
|
|
107
115
|
/** Assign a name to this route (for future URL generation). */
|
|
108
116
|
as(name: string): this {
|
|
109
|
-
this.
|
|
117
|
+
this.routeName = name
|
|
118
|
+
|
|
119
|
+
// Define a getter that builds the full name lazily
|
|
120
|
+
Object.defineProperty(this.route, 'name', {
|
|
121
|
+
get: () => {
|
|
122
|
+
const aliases = this.groupRefs
|
|
123
|
+
.map(ref => ref?.getAlias())
|
|
124
|
+
.filter(alias => alias)
|
|
125
|
+
|
|
126
|
+
const groupAlias = aliases.join('.')
|
|
127
|
+
return groupAlias ? `${groupAlias}.${this.routeName}` : this.routeName
|
|
128
|
+
},
|
|
129
|
+
configurable: true
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return this
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// GroupRef — returned by group methods for chaining (.as)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
class GroupRef {
|
|
141
|
+
private alias?: string
|
|
142
|
+
|
|
143
|
+
constructor(
|
|
144
|
+
private router: Router,
|
|
145
|
+
private groupState: GroupState,
|
|
146
|
+
private callback: (router: Router) => void
|
|
147
|
+
) {
|
|
148
|
+
// Store reference to this GroupRef in the state
|
|
149
|
+
this.groupState.ref = this
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Assign an alias to this group (for hierarchical route naming). */
|
|
153
|
+
as(alias: string): this {
|
|
154
|
+
this.alias = alias
|
|
155
|
+
this.groupState.alias = alias
|
|
110
156
|
return this
|
|
111
157
|
}
|
|
158
|
+
|
|
159
|
+
/** @internal Get the alias assigned to this group */
|
|
160
|
+
getAlias(): string | undefined {
|
|
161
|
+
return this.alias
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @internal Execute the group callback with the current state */
|
|
165
|
+
execute(): void {
|
|
166
|
+
this.router.groupStack.push(this.groupState)
|
|
167
|
+
this.callback(this.router)
|
|
168
|
+
this.router.groupStack.pop()
|
|
169
|
+
}
|
|
112
170
|
}
|
|
113
171
|
|
|
114
172
|
// ---------------------------------------------------------------------------
|
|
@@ -321,8 +379,13 @@ export default class Router {
|
|
|
321
379
|
* router.group({ prefix: '/api', middleware: [auth] }, (r) => {
|
|
322
380
|
* r.get('/users', listUsers)
|
|
323
381
|
* })
|
|
382
|
+
*
|
|
383
|
+
* @example With group aliasing:
|
|
384
|
+
* router.group({ prefix: '/api' }, (r) => {
|
|
385
|
+
* r.get('/users', listUsers).as('index')
|
|
386
|
+
* }).as('api')
|
|
324
387
|
*/
|
|
325
|
-
group(options: GroupOptions, callback: (router: Router) => void):
|
|
388
|
+
group(options: GroupOptions, callback: (router: Router) => void): GroupRef {
|
|
326
389
|
const parent = this.currentGroup()
|
|
327
390
|
const prefix = (parent?.prefix ?? '') + (options.prefix ?? '')
|
|
328
391
|
const middleware = [...(parent?.middleware ?? []), ...(options.middleware ?? [])]
|
|
@@ -336,9 +399,21 @@ export default class Router {
|
|
|
336
399
|
subdomainParamName = parsed.paramName
|
|
337
400
|
}
|
|
338
401
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
402
|
+
const groupState: GroupState = {
|
|
403
|
+
prefix,
|
|
404
|
+
middleware,
|
|
405
|
+
subdomain,
|
|
406
|
+
subdomainParamName,
|
|
407
|
+
alias: undefined
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const ref = new GroupRef(this, groupState, callback)
|
|
411
|
+
|
|
412
|
+
// Execute immediately for backward compatibility
|
|
413
|
+
// The group can still be chained with .as() but routes are registered immediately
|
|
414
|
+
ref.execute()
|
|
415
|
+
|
|
416
|
+
return ref
|
|
342
417
|
}
|
|
343
418
|
|
|
344
419
|
/**
|
|
@@ -354,8 +429,64 @@ export default class Router {
|
|
|
354
429
|
* // ctx.params.tenant === 'acme'
|
|
355
430
|
* })
|
|
356
431
|
*/
|
|
357
|
-
subdomain(pattern: string, callback: (router: Router) => void):
|
|
358
|
-
this.group({ subdomain: pattern }, callback)
|
|
432
|
+
subdomain(pattern: string, callback: (router: Router) => void): GroupRef {
|
|
433
|
+
return this.group({ subdomain: pattern }, callback)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---- Route lookup and URL generation ------------------------------------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get a route definition by its name.
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* const route = router.getRouteByName('users.show')
|
|
443
|
+
* // Returns route with pattern '/users/:id', method 'GET', etc.
|
|
444
|
+
*/
|
|
445
|
+
getRouteByName(name: string): RouteDefinition | undefined {
|
|
446
|
+
return this.routes.find(route => route.name === name)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Generate a URL for a named route with optional parameters.
|
|
451
|
+
*
|
|
452
|
+
* @example
|
|
453
|
+
* router.generateUrl('users.show', { id: 123 })
|
|
454
|
+
* // Returns '/users/123'
|
|
455
|
+
*
|
|
456
|
+
* router.generateUrl('api.search', { q: 'test', page: 2 })
|
|
457
|
+
* // Returns '/api/search?q=test&page=2'
|
|
458
|
+
*/
|
|
459
|
+
generateUrl(name: string, params?: Record<string, any>): string {
|
|
460
|
+
const route = this.getRouteByName(name)
|
|
461
|
+
if (!route) {
|
|
462
|
+
throw new Error(`Route '${name}' not found`)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let url = route.pattern
|
|
466
|
+
const usedParams = new Set<string>()
|
|
467
|
+
|
|
468
|
+
// Replace route parameters
|
|
469
|
+
for (const paramName of route.paramNames) {
|
|
470
|
+
if (params?.[paramName] !== undefined) {
|
|
471
|
+
url = url.replace(`:${paramName}`, String(params[paramName]))
|
|
472
|
+
usedParams.add(paramName)
|
|
473
|
+
} else {
|
|
474
|
+
throw new Error(`Missing required parameter '${paramName}' for route '${name}'`)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Add remaining params as query string
|
|
479
|
+
if (params) {
|
|
480
|
+
const queryParams = Object.entries(params)
|
|
481
|
+
.filter(([key]) => !usedParams.has(key))
|
|
482
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|
483
|
+
|
|
484
|
+
if (queryParams.length > 0) {
|
|
485
|
+
url += '?' + queryParams.join('&')
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return url
|
|
359
490
|
}
|
|
360
491
|
|
|
361
492
|
// ---- Dispatch ------------------------------------------------------------
|
|
@@ -496,6 +627,14 @@ export default class Router {
|
|
|
496
627
|
return this.currentGroup()?.prefix ?? ''
|
|
497
628
|
}
|
|
498
629
|
|
|
630
|
+
/** @internal Get the concatenated alias chain from all parent groups */
|
|
631
|
+
getCurrentGroupAlias(): string {
|
|
632
|
+
const aliases = this.groupStack
|
|
633
|
+
.filter(group => group.alias)
|
|
634
|
+
.map(group => group.alias)
|
|
635
|
+
return aliases.join('.')
|
|
636
|
+
}
|
|
637
|
+
|
|
499
638
|
/** Resolve a `[Controller, 'method']` tuple into a Handler. */
|
|
500
639
|
private toHandler(input: HandlerInput): Handler {
|
|
501
640
|
if (Array.isArray(input)) {
|
|
@@ -523,7 +662,7 @@ export default class Router {
|
|
|
523
662
|
}
|
|
524
663
|
|
|
525
664
|
this.routes.push(route)
|
|
526
|
-
return new RouteRef(route)
|
|
665
|
+
return new RouteRef(route, this)
|
|
527
666
|
}
|
|
528
667
|
|
|
529
668
|
private extractSubdomain(request: Request): string {
|