Skip to content

Commit c307d6c

Browse files
committed
feat: add route metadata helper
1 parent 4459a28 commit c307d6c

10 files changed

Lines changed: 297 additions & 112 deletions

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Import the primary API from the root package and declare routes through the HTTP
5959
subpath:
6060

6161
```ts
62-
import { $error, $type, chain, createClient, createRouter } from 'rouzer'
62+
import { $error, $type, chain, createClient, createRouter, metadata } from 'rouzer'
6363
import * as http from 'rouzer/http'
6464
```
6565

@@ -181,6 +181,29 @@ await client.upload(file, { headers: { 'content-type': file.type } })
181181
Server handlers for raw-body routes read from `ctx.request` directly with Fetch
182182
APIs such as `arrayBuffer()`, `blob()`, `formData()`, or `text()`.
183183

184+
### Route metadata
185+
186+
Use `metadata(...)` to attach optional runtime metadata to HTTP resources or
187+
actions. Metadata does not affect routing, validation, client typing, or handler
188+
behavior; it is preserved on route nodes for generated clients, CLIs, docs, and
189+
route inspectors.
190+
191+
```ts
192+
export const sessions = http.resource('sessions', {
193+
...metadata({
194+
description: 'Daemon-managed session control.',
195+
}),
196+
list: http.post('list', {
197+
...metadata({
198+
description: 'Lists daemon-managed sessions and pagination state.',
199+
}),
200+
response: $type<SessionList>(),
201+
}),
202+
})
203+
```
204+
205+
The constructed nodes expose metadata as `node.metadata`.
206+
184207
### Client lifecycle hooks
185208

186209
Pass `clientHook` to observe generated client action calls without wrapping the

docs/context.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,29 @@ paths are joined, so the examples above expose `profiles/:id`, `profiles/:id`,
7272
and `profiles/:id/posts`. Path params from parent resources are accumulated into
7373
child action types.
7474

75+
Use the root `metadata(...)` helper to attach optional runtime metadata to
76+
resources or actions without changing route matching, validation, client typing,
77+
or handler behavior:
78+
79+
```ts
80+
import { metadata } from 'rouzer'
81+
82+
export const sessions = http.resource('sessions', {
83+
...metadata({
84+
description: 'Daemon-managed session control.',
85+
}),
86+
list: http.post('list', {
87+
...metadata({
88+
description: 'Lists daemon-managed sessions and pagination state.',
89+
}),
90+
response: $type<SessionList>(),
91+
}),
92+
})
93+
```
94+
95+
Constructed route nodes expose metadata through `node.metadata` for generated
96+
clients, CLIs, docs, and route inspectors.
97+
7598
Patterns are parsed by `@remix-run/route-pattern` v0.21. Params can be inferred
7699
from patterns such as `hello/:name`, `v:major.:minor`,
77100
`api(/v:major(.:minor))`, `assets/*path`, and `search?q`. Full URL patterns such

src/http.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import { RoutePattern } from '@remix-run/route-pattern'
2+
import {
3+
getRouteMetadata,
4+
stripRouteMetadata,
5+
type RouteMetadata,
6+
type RouteMetadataMarker,
7+
} from './metadata.js'
28
import type { RawBodySchema, RouteSchema } from './types/schema.js'
39

410
/** HTTP methods supported by Rouzer action declarations. */
@@ -23,6 +29,8 @@ export type HttpAction<
2329
method: M
2430
/** Request validation and optional response type schema. */
2531
schema: T
32+
/** Optional runtime metadata for generated tooling. */
33+
metadata?: RouteMetadata
2634
}
2735

2836
/**
@@ -41,6 +49,8 @@ export type HttpResource<
4149
path: RoutePattern<P>
4250
/** Child resources and actions exposed below this resource. */
4351
children: TChildren
52+
/** Optional runtime metadata for generated tooling. */
53+
metadata?: RouteMetadata
4454
}
4555

4656
/** Node type accepted inside an HTTP route tree. */
@@ -49,6 +59,10 @@ export type HttpNode = HttpAction | HttpResource
4959
/** Route tree accepted by HTTP clients and routers. */
5060
export type HttpRouteTree = { [key: string]: HttpNode }
5161

62+
type RouteDeclaration<T extends object> = T & Partial<RouteMetadataMarker>
63+
64+
type StringKeys<T> = Pick<T, Extract<keyof T, string>>
65+
5266
/**
5367
* Declare an HTTP resource namespace.
5468
*
@@ -59,85 +73,90 @@ export type HttpRouteTree = { [key: string]: HttpNode }
5973
export function resource<
6074
const P extends string,
6175
const TChildren extends HttpRouteTree,
62-
>(path: P, children: TChildren): HttpResource<P, TChildren> {
76+
>(
77+
path: P,
78+
children: RouteDeclaration<TChildren>
79+
): HttpResource<P, StringKeys<TChildren>> {
80+
const metadata = getRouteMetadata(children)
6381
return {
6482
kind: 'resource',
6583
path: RoutePattern.parse(path),
66-
children,
84+
children: stripRouteMetadata(children) as unknown as StringKeys<TChildren>,
85+
metadata,
6786
}
6887
}
6988

7089
/** Declare a GET action, optionally with an action-local path segment. */
7190
export function get<const P extends string, const T extends RouteSchema>(
7291
path: P,
73-
schema: T
92+
schema: RouteDeclaration<T>
7493
): HttpAction<P, T, 'GET'>
7594
export function get<const T extends RouteSchema>(
76-
schema: T
95+
schema: RouteDeclaration<T>
7796
): HttpAction<'', T, 'GET'>
7897
export function get(
79-
pathOrSchema: string | RouteSchema,
80-
schema?: RouteSchema
98+
pathOrSchema: string | RouteDeclaration<RouteSchema>,
99+
schema?: RouteDeclaration<RouteSchema>
81100
): any {
82101
return action('GET', pathOrSchema, schema)
83102
}
84103

85104
/** Declare a POST action, optionally with an action-local path segment. */
86105
export function post<const P extends string, const T extends RouteSchema>(
87106
path: P,
88-
schema: T
107+
schema: RouteDeclaration<T>
89108
): HttpAction<P, T, 'POST'>
90109
export function post<const T extends RouteSchema>(
91-
schema: T
110+
schema: RouteDeclaration<T>
92111
): HttpAction<'', T, 'POST'>
93112
export function post(
94-
pathOrSchema: string | RouteSchema,
95-
schema?: RouteSchema
113+
pathOrSchema: string | RouteDeclaration<RouteSchema>,
114+
schema?: RouteDeclaration<RouteSchema>
96115
): any {
97116
return action('POST', pathOrSchema, schema)
98117
}
99118

100119
/** Declare a PUT action, optionally with an action-local path segment. */
101120
export function put<const P extends string, const T extends RouteSchema>(
102121
path: P,
103-
schema: T
122+
schema: RouteDeclaration<T>
104123
): HttpAction<P, T, 'PUT'>
105124
export function put<const T extends RouteSchema>(
106-
schema: T
125+
schema: RouteDeclaration<T>
107126
): HttpAction<'', T, 'PUT'>
108127
export function put(
109-
pathOrSchema: string | RouteSchema,
110-
schema?: RouteSchema
128+
pathOrSchema: string | RouteDeclaration<RouteSchema>,
129+
schema?: RouteDeclaration<RouteSchema>
111130
): any {
112131
return action('PUT', pathOrSchema, schema)
113132
}
114133

115134
/** Declare a PATCH action, optionally with an action-local path segment. */
116135
export function patch<const P extends string, const T extends RouteSchema>(
117136
path: P,
118-
schema: T
137+
schema: RouteDeclaration<T>
119138
): HttpAction<P, T, 'PATCH'>
120139
export function patch<const T extends RouteSchema>(
121-
schema: T
140+
schema: RouteDeclaration<T>
122141
): HttpAction<'', T, 'PATCH'>
123142
export function patch(
124-
pathOrSchema: string | RouteSchema,
125-
schema?: RouteSchema
143+
pathOrSchema: string | RouteDeclaration<RouteSchema>,
144+
schema?: RouteDeclaration<RouteSchema>
126145
): any {
127146
return action('PATCH', pathOrSchema, schema)
128147
}
129148

130149
/** Declare a DELETE action, optionally with an action-local path segment. */
131150
function deleteAction<const P extends string, const T extends RouteSchema>(
132151
path: P,
133-
schema: T
152+
schema: RouteDeclaration<T>
134153
): HttpAction<P, T, 'DELETE'>
135154
function deleteAction<const T extends RouteSchema>(
136-
schema: T
155+
schema: RouteDeclaration<T>
137156
): HttpAction<'', T, 'DELETE'>
138157
function deleteAction(
139-
pathOrSchema: string | RouteSchema,
140-
schema?: RouteSchema
158+
pathOrSchema: string | RouteDeclaration<RouteSchema>,
159+
schema?: RouteDeclaration<RouteSchema>
141160
): any {
142161
return action('DELETE', pathOrSchema, schema)
143162
}
@@ -163,13 +182,20 @@ export function isRawBodySchema(schema: unknown): schema is RawBodySchema {
163182

164183
function action(
165184
method: HttpMethod,
166-
pathOrSchema: string | RouteSchema,
167-
schema?: RouteSchema
185+
pathOrSchema: string | RouteDeclaration<RouteSchema>,
186+
schema?: RouteDeclaration<RouteSchema>
168187
) {
169188
const path =
170189
typeof pathOrSchema === 'string'
171190
? RoutePattern.parse(pathOrSchema)
172191
: undefined
173192
schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema
174-
return { kind: 'action', path, method, schema }
193+
const metadata = getRouteMetadata(schema)
194+
return {
195+
kind: 'action',
196+
path,
197+
method,
198+
schema: stripRouteMetadata(schema),
199+
metadata,
200+
}
175201
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './client/index.js'
2+
export { metadata, type RouteMetadata } from './metadata.js'
23
export * from './response.js'
34
export * from './type.js'
45
export * from './server/router.js'

src/metadata.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const routeMetadataKey: unique symbol = Symbol('rouzer.metadata')
2+
3+
/** Runtime metadata attached to Rouzer route nodes. */
4+
export type RouteMetadata = {
5+
/** Short label for generated indexes, clients, CLIs, or docs. */
6+
summary?: string
7+
/** Human-readable route description for generated tooling. */
8+
description?: string
9+
}
10+
11+
export type RouteMetadataMarker = {
12+
readonly [routeMetadataKey]: RouteMetadata
13+
}
14+
15+
/** Attach runtime metadata to a route declaration. */
16+
export function metadata(value: RouteMetadata): RouteMetadataMarker {
17+
return { [routeMetadataKey]: value }
18+
}
19+
20+
export function getRouteMetadata(value: unknown): RouteMetadata | undefined {
21+
return typeof value === 'object' && value !== null
22+
? (value as Partial<RouteMetadataMarker>)[routeMetadataKey]
23+
: undefined
24+
}
25+
26+
export function stripRouteMetadata<T extends object>(value: T) {
27+
const { [routeMetadataKey]: _metadata, ...rest } = value as T &
28+
Partial<RouteMetadataMarker>
29+
return rest as Omit<T, typeof routeMetadataKey>
30+
}

test/error-responses.test-d.ts

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { $type, $error, createClient, createRouter } from 'rouzer'
22
import * as http from 'rouzer/http'
3-
4-
type Equal<A, B> =
5-
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
6-
? true
7-
: false
8-
9-
type Assert<T extends true> = T
3+
import { expectTypeOf, test } from 'vitest'
104

115
// --- Route with response map ---
126

@@ -30,47 +24,48 @@ const client = createClient({
3024
routes,
3125
})
3226

33-
// Client action returns a discriminated tuple
3427
type GetUserResult = Awaited<ReturnType<typeof client.getUser>>
3528

36-
type _ClientReturnsDiscriminatedTuple = Assert<
37-
Equal<
38-
GetUserResult,
29+
test('response maps produce discriminated client tuples', () => {
30+
expectTypeOf<GetUserResult>().toEqualTypeOf<
3931
| [null, User, 200]
4032
| [null, User, 201]
4133
| [AuthError, null, 401]
4234
| [NotFoundError, null, 404]
43-
>
44-
>
35+
>()
36+
})
4537

46-
// Handler can return success value or ctx.error(...)
47-
createRouter().use(routes, {
48-
getUser(ctx) {
49-
if (ctx.path.id === 'missing') {
50-
return ctx.error(404, { code: 'NOT_FOUND', message: 'not found' })
51-
}
52-
if (ctx.path.id === 'unauthorized') {
53-
return ctx.error(401, { code: 'UNAUTHORIZED', message: 'no auth' })
54-
}
55-
if (ctx.path.id === 'created') {
56-
return ctx.success(201, { id: ctx.path.id, name: 'Ada' })
57-
}
58-
return { id: ctx.path.id, name: 'Ada' }
59-
},
38+
test('response map handlers can return success values and helpers', () => {
39+
createRouter().use(routes, {
40+
getUser(ctx) {
41+
if (ctx.path.id === 'missing') {
42+
return ctx.error(404, { code: 'NOT_FOUND', message: 'not found' })
43+
}
44+
if (ctx.path.id === 'unauthorized') {
45+
return ctx.error(401, { code: 'UNAUTHORIZED', message: 'no auth' })
46+
}
47+
if (ctx.path.id === 'created') {
48+
return ctx.success(201, { id: ctx.path.id, name: 'Ada' })
49+
}
50+
return { id: ctx.path.id, name: 'Ada' }
51+
},
52+
})
6053
})
6154

62-
createRouter().use(routes, {
63-
getUser(ctx) {
64-
// @ts-expect-error 500 is not a declared error status.
65-
ctx.error(500, { code: 'NOT_FOUND', message: 'nope' })
66-
// @ts-expect-error Error body must match the selected status.
67-
ctx.error(404, { code: 'UNAUTHORIZED', message: 'nope' })
68-
// @ts-expect-error 404 is not a declared success status.
69-
ctx.success(404, { code: 'NOT_FOUND', message: 'nope' })
70-
// @ts-expect-error Success body must match the selected status.
71-
ctx.success(201, { id: 123, name: 'Ada' })
72-
return { id: ctx.path.id, name: 'Ada' }
73-
},
55+
test('response map helpers reject undeclared statuses and mismatched bodies', () => {
56+
createRouter().use(routes, {
57+
getUser(ctx) {
58+
// @ts-expect-error 500 is not a declared error status.
59+
ctx.error(500, { code: 'NOT_FOUND', message: 'nope' })
60+
// @ts-expect-error Error body must match the selected status.
61+
ctx.error(404, { code: 'UNAUTHORIZED', message: 'nope' })
62+
// @ts-expect-error 404 is not a declared success status.
63+
ctx.success(404, { code: 'NOT_FOUND', message: 'nope' })
64+
// @ts-expect-error Success body must match the selected status.
65+
ctx.success(201, { id: 123, name: 'Ada' })
66+
return { id: ctx.path.id, name: 'Ada' }
67+
},
68+
})
7469
})
7570

7671
// --- Verify existing $type<T>() still works ---
@@ -87,6 +82,6 @@ const simpleClient = createClient({
8782

8883
type SimpleResult = Awaited<ReturnType<typeof simpleClient.simpleRoute>>
8984

90-
type _SimpleRouteStillReturnsPlainType = Assert<
91-
Equal<SimpleResult, { message: string }>
92-
>
85+
test('plain response markers still return plain client values', () => {
86+
expectTypeOf<SimpleResult>().toEqualTypeOf<{ message: string }>()
87+
})

0 commit comments

Comments
 (0)