Skip to content

Commit 688bb65

Browse files
committed
feat: add explicit response map success helper
Add ctx.success(status, body) for handlers that need to select among multiple declared success statuses. Route both ctx.success and ctx.error through the shared response-map encoder so undeclared statuses fail at runtime and plugin-backed success responses keep their per-status encoding.
1 parent c0fe45c commit 688bb65

9 files changed

Lines changed: 117 additions & 65 deletions

File tree

src/client/index.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js'
55
import {
66
createResponsePluginMap,
77
getResponsePluginMarkerId,
8-
responsePluginMarker,
98
type ClientResponsePlugin,
109
type ResponsePluginMarker,
1110
} from '../response.js'
@@ -17,7 +16,7 @@ import {
1716
import type { RouteArgs } from '../types/args.js'
1817
import type { RouteRequest } from '../types/request.js'
1918
import type { InferRouteResponse } from '../types/response.js'
20-
import type { RouteResponseMap, RouteSchema } from '../types/schema.js'
19+
import type { RouteSchema } from '../types/schema.js'
2120

2221
/** Client type inferred from an HTTP route tree passed to `createClient`. */
2322
export type RouzerClient<
@@ -151,10 +150,8 @@ export function createClient<
151150
// Handle status-keyed response maps
152151
if (isResponseMap(responseSchema)) {
153152
const status = httpResponse.status
154-
const statusKey = status as keyof typeof responseSchema
155-
if (statusKey in responseSchema) {
156-
const marker = responseSchema[statusKey]
157-
const body = await httpResponse.json()
153+
if (status in responseSchema) {
154+
const marker = responseSchema[status]
158155
if (isErrorMarker(marker)) {
159156
return [await httpResponse.json(), null, status] as T['$result']
160157
}
@@ -173,7 +170,7 @@ export function createClient<
173170
status,
174171
] as T['$result']
175172
}
176-
return [null, body, status] as T['$result']
173+
return [null, await httpResponse.json(), status] as T['$result']
177174
}
178175
// Undeclared status — reject
179176
return handleResponseError(httpResponse, props)
@@ -324,17 +321,3 @@ function joinPaths(left: string, right: string) {
324321
return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/')
325322
}
326323

327-
/** Return true when the response schema is a status-keyed response map. */
328-
function isResponseMap(
329-
response: RouteSchema['response']
330-
): response is RouteResponseMap {
331-
return (
332-
typeof response === 'object' &&
333-
response !== null &&
334-
!(responsePluginMarker in response)
335-
)
336-
}
337-
338-
function isErrorMarker(marker: unknown): boolean {
339-
return marker === $error.symbol
340-
}

src/server/router.ts

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,18 @@ class RouterObject extends MiddlewareChain {
222222
}
223223

224224
if (isResponseMap(schema.response)) {
225-
;(context as any).error = createErrorHelper()
225+
;(context as any).error = createResponseHelper(
226+
schema.response,
227+
request,
228+
responsePlugins,
229+
true
230+
)
231+
;(context as any).success = createResponseHelper(
232+
schema.response,
233+
request,
234+
responsePlugins,
235+
false
236+
)
226237
}
227238

228239
const result = await handler(context)
@@ -242,9 +253,14 @@ class RouterObject extends MiddlewareChain {
242253
})
243254
}
244255
if (isResponseMap(schema.response)) {
245-
// Success response from a response map — find the success status
246-
const status = getSuccessStatus(schema.response)
247-
return Response.json(result, { status })
256+
const status = getDefaultSuccessStatus(schema.response)
257+
return encodeResponseMapResult(
258+
schema.response,
259+
status,
260+
result,
261+
request,
262+
responsePlugins
263+
)
248264
}
249265
return Response.json(result)
250266
}
@@ -350,9 +366,15 @@ function validateRouterResponsePlugins(
350366
plugins: Map<string, RouterResponsePlugin>
351367
) {
352368
for (const route of routes) {
353-
const pluginId = getResponsePluginMarkerId(route.schema.response)
354-
if (pluginId && !plugins.has(pluginId)) {
355-
throw missingRouterResponsePlugin(pluginId)
369+
const pluginIds = isResponseMap(route.schema.response)
370+
? getResponseMapPluginIds(route.schema.response)
371+
: [getResponsePluginMarkerId(route.schema.response)].filter(
372+
pluginId => pluginId !== undefined
373+
)
374+
for (const pluginId of pluginIds) {
375+
if (!plugins.has(pluginId)) {
376+
throw missingRouterResponsePlugin(pluginId)
377+
}
356378
}
357379
}
358380
}
@@ -497,39 +519,28 @@ function createOriginPattern(origin: string) {
497519
return new ExactPattern(origin)
498520
}
499521

500-
/** Return true when the response schema is a status-keyed response map. */
501-
function isResponseMap(
502-
response: RouteSchema['response']
503-
): response is RouteResponseMap {
504-
return (
505-
typeof response === 'object' &&
506-
response !== null &&
507-
!(responsePluginMarker in response)
508-
)
509-
}
510-
511-
import { responsePluginMarker } from '../response.js'
512-
513-
/** Create the `ctx.error(status, body)` helper for route handlers. */
514-
function createErrorHelper() {
515-
return (status: number, body: unknown): Response => {
516-
return Response.json(body, { status })
517-
}
518-
}
519-
520-
/**
521-
* Find the first success status in a response map (a status whose marker is
522-
* `$type<T>()` or a plugin marker, not `$error<T>()`).
523-
*/
524-
function getSuccessStatus(responseMap: RouteResponseMap): number {
525-
for (const key of Object.keys(responseMap)) {
526-
const status = Number(key)
527-
const marker = (responseMap as any)[status]
528-
if (!isErrorMarker(marker)) {
529-
return status
522+
/** Create `ctx.error(status, body)` or `ctx.success(status, body)`. */
523+
function createResponseHelper(
524+
responseMap: RouteResponseMap,
525+
request: Request,
526+
responsePlugins: Map<string, RouterResponsePlugin>,
527+
error: boolean
528+
) {
529+
return (status: number, body: unknown): Promise<Response> | Response => {
530+
const marker = responseMap[status]
531+
if (!marker || isErrorMarker(marker) !== error) {
532+
throw new Error(
533+
`Undeclared ${error ? 'error' : 'success'} response status: ${status}`
534+
)
530535
}
536+
return encodeResponseMapResult(
537+
responseMap,
538+
status,
539+
body,
540+
request,
541+
responsePlugins
542+
)
531543
}
532-
return 200
533544
}
534545

535546
async function encodeResponseMapResult(

src/types/handler.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { HttpAction } from '../http.js'
66
import type {
77
InferRouteHandlerResult,
88
InferResponseMapErrors,
9+
InferResponseMapSuccesses,
910
} from './response.js'
1011
import type { RouteResponseMap, RouteSchema } from './schema.js'
1112

@@ -20,11 +21,15 @@ type RequestContext<TMiddleware extends AnyMiddlewareChain> =
2021
*/
2122
export type RouteErrorResponse = Response & { __routeError__: true }
2223

24+
/** Response returned by `ctx.success(status, body)` in route handlers. */
25+
export type RouteSuccessResponse = Response & { __routeSuccess__: true }
26+
2327
export type RouteRequestHandler<
2428
TMiddleware extends AnyMiddlewareChain,
2529
TArgs extends object,
2630
TResult,
2731
TErrors = never,
32+
TSuccesses = never,
2833
> = (
2934
context: RequestContext<TMiddleware> &
3035
TArgs &
@@ -42,6 +47,20 @@ export type RouteRequestHandler<
4247
? [status: S, body: B]
4348
: never
4449
) => RouteErrorResponse
50+
}) &
51+
([TSuccesses] extends [never]
52+
? {}
53+
: {
54+
/**
55+
* Return a declared success response with an explicit status.
56+
*
57+
* @remarks Useful when a response map declares multiple 2xx statuses.
58+
*/
59+
success: <TEntry extends TSuccesses>(
60+
...args: TEntry extends [infer S extends number, infer B]
61+
? [status: S, body: B]
62+
: never
63+
) => RouteSuccessResponse
4564
})
4665
) => Promisable<TResult | Response>
4766

@@ -65,7 +84,8 @@ export type InferActionHandler<
6584
: undefined
6685
},
6786
InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>,
68-
InferResponseMapErrors<R>
87+
InferResponseMapErrors<R>,
88+
InferResponseMapSuccesses<R>
6989
>
7090
: RouteRequestHandler<
7191
TMiddleware,
@@ -81,7 +101,8 @@ export type InferActionHandler<
81101
: undefined
82102
},
83103
InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>,
84-
InferResponseMapErrors<R>
104+
InferResponseMapErrors<R>,
105+
InferResponseMapSuccesses<R>
85106
>
86107
: TAction['method'] extends 'GET'
87108
? RouteRequestHandler<

src/types/response.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,12 @@ export type InferResponseMapErrors<T extends RouteResponseMap> = {
7272
? [K, TError]
7373
: never
7474
}[keyof T & number]
75+
76+
/** Extract success entries as a union of `[status, body]` pairs. */
77+
export type InferResponseMapSuccesses<T extends RouteResponseMap> = {
78+
[K in keyof T & number]: T[K] extends Unchecked<infer TSuccess>
79+
? [K, TSuccess]
80+
: T[K] extends ResponsePluginMarker<any, infer TRouter>
81+
? [K, TRouter]
82+
: never
83+
}[keyof T & number]

src/types/schema.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ import type { ResponsePluginMarker } from '../response.js'
99
* @remarks Application code should usually call `$type<T>()` instead of naming
1010
* this marker directly.
1111
*/
12-
export type { Unchecked }
13-
export type { UncheckedError }
14-
export type { ResponsePluginMarker }
12+
export type { ResponsePluginMarker, Unchecked, UncheckedError }
1513

1614
/** Single response marker accepted by status-keyed response maps. */
1715
export type RouteResponseMarker =

test/error-responses.test-d.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type AuthError = { code: 'UNAUTHORIZED'; message: string }
1717
const getUser = http.get('users/:id', {
1818
response: {
1919
200: $type<User>(),
20+
201: $type<User>(),
2021
401: $error<AuthError>(),
2122
404: $error<NotFoundError>(),
2223
},
@@ -35,7 +36,10 @@ type GetUserResult = Awaited<ReturnType<typeof client.getUser>>
3536
type _ClientReturnsDiscriminatedTuple = Assert<
3637
Equal<
3738
GetUserResult,
38-
[null, User, 200] | [AuthError, null, 401] | [NotFoundError, null, 404]
39+
| [null, User, 200]
40+
| [null, User, 201]
41+
| [AuthError, null, 401]
42+
| [NotFoundError, null, 404]
3943
>
4044
>
4145

@@ -48,6 +52,23 @@ createRouter().use(routes, {
4852
if (ctx.path.id === 'unauthorized') {
4953
return ctx.error(401, { code: 'UNAUTHORIZED', message: 'no auth' })
5054
}
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+
},
60+
})
61+
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' })
5172
return { id: ctx.path.id, name: 'Ada' }
5273
},
5374
})

test/fixtures/error-responses/handler.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export default createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, {
1313
})
1414
}
1515

16+
if (id === 'created') {
17+
return ctx.success(201, {
18+
id,
19+
name: 'Grace',
20+
})
21+
}
22+
1623
if (id === 'missing') {
1724
return ctx.error(404, {
1825
code: 'NOT_FOUND',

test/fixtures/error-responses/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type AuthError = {
2020
export const getUser = http.get('users/:id', {
2121
response: {
2222
200: $type<User>(),
23+
201: $type<User>(),
2324
401: $error<AuthError>(),
2425
404: $error<NotFoundError>(),
2526
},

test/fixtures/error-responses/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default createTest({
77
name: 'typed error responses ($error<T> with status-keyed response map)',
88
routes,
99
handler,
10+
clientPlugins: [ndjson.clientPlugin],
1011
test: async client => {
1112
// Success case: returns tuple [null, User, 200]
1213
const [error1, result1, status1] = await client.getUser({

0 commit comments

Comments
 (0)