@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/http",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "HTTP layer for the Strav framework — router, server, middleware, authentication, sessions, validation, and views",
6
6
  "license": "MIT",
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
+ }
@@ -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
- constructor(private route: RouteDefinition) {}
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.route.name = name
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): 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
- this.groupStack.push({ prefix, middleware, subdomain, subdomainParamName })
340
- callback(this)
341
- this.groupStack.pop()
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): 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 {