rouzer 3.1.0 → 3.2.0
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/README.md +52 -6
- package/dist/client/index.d.ts +4 -2
- package/dist/client/index.js +39 -5
- package/dist/common.d.ts +11 -1
- package/dist/http.js +3 -1
- package/dist/response-map.d.ts +10 -0
- package/dist/response-map.js +32 -0
- package/dist/response.d.ts +5 -3
- package/dist/response.js +4 -6
- package/dist/server/router.js +53 -3
- package/dist/type.d.ts +33 -4
- package/dist/type.js +32 -3
- package/dist/types/handler.d.ts +54 -4
- package/dist/types/response.d.ts +51 -11
- package/dist/types/schema.d.ts +15 -4
- package/docs/context.md +128 -24
- package/examples/error-responses.ts +98 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,14 +6,14 @@ and Zod validation between a Hattip-compatible server and a typed fetch client.
|
|
|
6
6
|
## What it does
|
|
7
7
|
|
|
8
8
|
A Rouzer HTTP route tree defines URL patterns, named actions, method schemas, and
|
|
9
|
-
optional JSON or newline-delimited JSON response types once, then reuses
|
|
10
|
-
contract to:
|
|
9
|
+
optional JSON, error, or newline-delimited JSON response types once, then reuses
|
|
10
|
+
that contract to:
|
|
11
11
|
|
|
12
12
|
- validate client arguments before `fetch`
|
|
13
13
|
- match and validate server requests before handlers run
|
|
14
14
|
- type handler context from path, query/body, headers, and middleware
|
|
15
15
|
- attach typed client action functions such as `client.profiles.get(...)`
|
|
16
|
-
- parse typed JSON responses and
|
|
16
|
+
- parse typed JSON responses, declared error responses, and NDJSON streams
|
|
17
17
|
|
|
18
18
|
Rouzer optimizes for shared TypeScript route modules over language-agnostic API
|
|
19
19
|
schemas or generated SDKs.
|
|
@@ -24,6 +24,8 @@ Use Rouzer if:
|
|
|
24
24
|
|
|
25
25
|
- your server and client can import the same TypeScript route tree
|
|
26
26
|
- you want Zod request validation on both sides of an HTTP boundary
|
|
27
|
+
- response data is validated at data/client boundaries, not by re-checking every
|
|
28
|
+
handler return
|
|
27
29
|
- a Hattip-compatible handler fits your server runtime
|
|
28
30
|
- you prefer named resource/action functions over a generated client class
|
|
29
31
|
|
|
@@ -31,8 +33,8 @@ Consider something else if:
|
|
|
31
33
|
|
|
32
34
|
- you need OpenAPI-first workflows, schema files, or generated clients for other
|
|
33
35
|
languages
|
|
34
|
-
- you
|
|
35
|
-
`
|
|
36
|
+
- you want the router to validate every response body at the server boundary;
|
|
37
|
+
`$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are type contracts
|
|
36
38
|
- you want a framework that owns controllers, data loading, rendering, and
|
|
37
39
|
deployment adapters
|
|
38
40
|
- you cannot use ESM or Zod v4+
|
|
@@ -55,7 +57,7 @@ Import the primary API from the root package and declare routes through the HTTP
|
|
|
55
57
|
subpath:
|
|
56
58
|
|
|
57
59
|
```ts
|
|
58
|
-
import { $type, chain, createClient, createRouter } from 'rouzer'
|
|
60
|
+
import { $error, $type, chain, createClient, createRouter } from 'rouzer'
|
|
59
61
|
import * as http from 'rouzer/http'
|
|
60
62
|
```
|
|
61
63
|
|
|
@@ -103,6 +105,49 @@ const { message } = await client.hello({
|
|
|
103
105
|
route arguments before `fetch`; server handlers validate matched path, query,
|
|
104
106
|
headers, and JSON bodies before your handler runs.
|
|
105
107
|
|
|
108
|
+
### Typed status responses
|
|
109
|
+
|
|
110
|
+
Use a response map when client code needs declared error statuses as data instead
|
|
111
|
+
of exceptions.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { $error, $type, createClient, createRouter } from 'rouzer'
|
|
115
|
+
import * as http from 'rouzer/http'
|
|
116
|
+
|
|
117
|
+
type User = { id: string; name: string }
|
|
118
|
+
type NotFound = { code: 'NOT_FOUND'; message: string }
|
|
119
|
+
|
|
120
|
+
export const getUser = http.get('users/:id', {
|
|
121
|
+
response: {
|
|
122
|
+
200: $type<User>(),
|
|
123
|
+
404: $error<NotFound>(),
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
export const routes = { getUser }
|
|
127
|
+
|
|
128
|
+
createRouter().use(routes, {
|
|
129
|
+
getUser(ctx) {
|
|
130
|
+
if (ctx.path.id === 'missing') {
|
|
131
|
+
return ctx.error(404, {
|
|
132
|
+
code: 'NOT_FOUND',
|
|
133
|
+
message: 'User not found',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
return { id: ctx.path.id, name: 'Ada' }
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const client = createClient({
|
|
141
|
+
baseURL: 'https://example.com/api/',
|
|
142
|
+
routes,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const [error, user, status] = await client.getUser({ path: { id: '42' } })
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Success entries resolve as `[null, value, status]`; declared error entries
|
|
149
|
+
resolve as `[error, null, status]`.
|
|
150
|
+
|
|
106
151
|
### NDJSON response streams
|
|
107
152
|
|
|
108
153
|
Use `response: ndjson.$type<T>()` for endpoints that stream
|
|
@@ -141,6 +186,7 @@ for await (const event of await client.events()) {
|
|
|
141
186
|
|
|
142
187
|
- [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
|
|
143
188
|
- [Runnable shared-route example](examples/basic-usage.ts)
|
|
189
|
+
- [Runnable typed error response example](examples/error-responses.ts)
|
|
144
190
|
- [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
|
|
145
191
|
- Generated declarations in the published package provide the exact signatures
|
|
146
192
|
for every public export, including the `rouzer/http` and `rouzer/ndjson`
|
package/dist/client/index.d.ts
CHANGED
|
@@ -106,8 +106,10 @@ export type ClientTree<T extends HttpRouteTree, TPrefix extends string = ''> = {
|
|
|
106
106
|
* Client action function attached for each HTTP action leaf.
|
|
107
107
|
*
|
|
108
108
|
* @remarks Actions whose schema has `response: $type<T>()` return parsed JSON
|
|
109
|
-
* as `T`. Actions whose schema has a
|
|
110
|
-
*
|
|
109
|
+
* as `T`. Actions whose schema has a status-keyed response map return a tuple
|
|
110
|
+
* union of `[null, value, status]` success entries and `[error, null, status]`
|
|
111
|
+
* error entries. Actions whose schema has a plugin response marker return the
|
|
112
|
+
* plugin's client result type. Actions without a response marker return the raw
|
|
111
113
|
* `Response`.
|
|
112
114
|
*/
|
|
113
115
|
export type RouteFunction<T extends RouteSchema, P extends string> = (...p: RouteArgs<T, P> extends infer TArgs ? {} extends TArgs ? [args?: TArgs] : [args: TArgs] : never) => Promise<T extends {
|
package/dist/client/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { RoutePattern } from '@remix-run/route-pattern';
|
|
|
2
2
|
import { createHref } from '@remix-run/route-pattern/href';
|
|
3
3
|
import { shake } from '../common.js';
|
|
4
4
|
import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
|
|
5
|
+
import { getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
|
|
5
6
|
/**
|
|
6
7
|
* Create a typed fetch client for an HTTP route tree.
|
|
7
8
|
*
|
|
@@ -69,17 +70,46 @@ export function createClient(config) {
|
|
|
69
70
|
}
|
|
70
71
|
async function response(props) {
|
|
71
72
|
const httpResponse = await request(props);
|
|
73
|
+
const responseSchema = props.schema.response;
|
|
74
|
+
// Handle status-keyed response maps
|
|
75
|
+
if (isResponseMap(responseSchema)) {
|
|
76
|
+
const status = httpResponse.status;
|
|
77
|
+
if (status in responseSchema) {
|
|
78
|
+
const marker = responseSchema[status];
|
|
79
|
+
if (isErrorMarker(marker)) {
|
|
80
|
+
return [await httpResponse.json(), null, status];
|
|
81
|
+
}
|
|
82
|
+
const pluginId = getResponsePluginMarkerId(marker);
|
|
83
|
+
if (pluginId) {
|
|
84
|
+
const plugin = responsePlugins.get(pluginId);
|
|
85
|
+
if (!plugin) {
|
|
86
|
+
throw missingClientResponsePlugin(pluginId);
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
null,
|
|
90
|
+
await plugin.decode(httpResponse, {
|
|
91
|
+
marker: marker,
|
|
92
|
+
request: props,
|
|
93
|
+
}),
|
|
94
|
+
status,
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
return [null, await httpResponse.json(), status];
|
|
98
|
+
}
|
|
99
|
+
// Undeclared status — reject
|
|
100
|
+
return handleResponseError(httpResponse, props);
|
|
101
|
+
}
|
|
72
102
|
if (!httpResponse.ok) {
|
|
73
103
|
return handleResponseError(httpResponse, props);
|
|
74
104
|
}
|
|
75
|
-
const pluginId = getResponsePluginMarkerId(
|
|
105
|
+
const pluginId = getResponsePluginMarkerId(responseSchema);
|
|
76
106
|
if (pluginId) {
|
|
77
107
|
const plugin = responsePlugins.get(pluginId);
|
|
78
108
|
if (!plugin) {
|
|
79
109
|
throw missingClientResponsePlugin(pluginId);
|
|
80
110
|
}
|
|
81
111
|
return plugin.decode(httpResponse, {
|
|
82
|
-
marker:
|
|
112
|
+
marker: responseSchema,
|
|
83
113
|
request: props,
|
|
84
114
|
});
|
|
85
115
|
}
|
|
@@ -133,9 +163,13 @@ function validateClientResponsePlugins(tree, plugins) {
|
|
|
133
163
|
validateClientResponsePlugins(node.children, plugins);
|
|
134
164
|
}
|
|
135
165
|
else {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
166
|
+
const pluginIds = isResponseMap(node.schema.response)
|
|
167
|
+
? getResponseMapPluginIds(node.schema.response)
|
|
168
|
+
: [getResponsePluginMarkerId(node.schema.response)].filter(pluginId => pluginId !== undefined);
|
|
169
|
+
for (const pluginId of pluginIds) {
|
|
170
|
+
if (!plugins.has(pluginId)) {
|
|
171
|
+
throw missingClientResponsePlugin(pluginId);
|
|
172
|
+
}
|
|
139
173
|
}
|
|
140
174
|
}
|
|
141
175
|
}
|
package/dist/common.d.ts
CHANGED
|
@@ -6,9 +6,19 @@ export type Promisable<T> = T | Promise<T>;
|
|
|
6
6
|
* @remarks Consumers usually use `$type<T>()` instead of constructing this type
|
|
7
7
|
* directly.
|
|
8
8
|
*/
|
|
9
|
-
export type Unchecked<T> = {
|
|
9
|
+
export type Unchecked<T> = Record<number, unknown> & {
|
|
10
10
|
__unchecked__: T;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Compile-time-only marker used by `$error<T>()` to carry a declared error
|
|
14
|
+
* response type through route declarations.
|
|
15
|
+
*
|
|
16
|
+
* @remarks Consumers usually use `$error<T>()` instead of constructing this
|
|
17
|
+
* type directly.
|
|
18
|
+
*/
|
|
19
|
+
export type UncheckedError<T> = {
|
|
20
|
+
__uncheckedError__: T;
|
|
21
|
+
};
|
|
12
22
|
/**
|
|
13
23
|
* Map over all the keys to create a new object.
|
|
14
24
|
*
|
package/dist/http.js
CHANGED
|
@@ -30,7 +30,9 @@ function deleteAction(pathOrSchema, schema) {
|
|
|
30
30
|
}
|
|
31
31
|
export { deleteAction as delete };
|
|
32
32
|
function action(method, pathOrSchema, schema) {
|
|
33
|
-
const path = typeof pathOrSchema === 'string'
|
|
33
|
+
const path = typeof pathOrSchema === 'string'
|
|
34
|
+
? RoutePattern.parse(pathOrSchema)
|
|
35
|
+
: undefined;
|
|
34
36
|
schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema;
|
|
35
37
|
const request = ((args = {}) => ({
|
|
36
38
|
schema,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RouteResponseMap, RouteSchema } from './types/schema.js';
|
|
2
|
+
/** Return true when the response schema is a status-keyed response map. */
|
|
3
|
+
export declare function isResponseMap(response: RouteSchema['response']): response is RouteResponseMap;
|
|
4
|
+
/** Return true when the marker represents a declared error response. */
|
|
5
|
+
export declare function isErrorMarker(marker: unknown): boolean;
|
|
6
|
+
/** Return true when the marker represents a success response. */
|
|
7
|
+
export declare function isSuccessMarker(marker: unknown): boolean;
|
|
8
|
+
/** Find the default success status for a direct handler result. */
|
|
9
|
+
export declare function getResponseMapPluginIds(responseMap: RouteResponseMap): string[];
|
|
10
|
+
export declare function getDefaultSuccessStatus(responseMap: RouteResponseMap): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getResponsePluginMarkerId, responsePluginMarker, } from './response.js';
|
|
2
|
+
import { $error } from './type.js';
|
|
3
|
+
/** Return true when the response schema is a status-keyed response map. */
|
|
4
|
+
export function isResponseMap(response) {
|
|
5
|
+
return (typeof response === 'object' &&
|
|
6
|
+
response !== null &&
|
|
7
|
+
!(responsePluginMarker in response));
|
|
8
|
+
}
|
|
9
|
+
/** Return true when the marker represents a declared error response. */
|
|
10
|
+
export function isErrorMarker(marker) {
|
|
11
|
+
return marker === $error.symbol;
|
|
12
|
+
}
|
|
13
|
+
/** Return true when the marker represents a success response. */
|
|
14
|
+
export function isSuccessMarker(marker) {
|
|
15
|
+
return marker !== undefined && !isErrorMarker(marker);
|
|
16
|
+
}
|
|
17
|
+
/** Find the default success status for a direct handler result. */
|
|
18
|
+
export function getResponseMapPluginIds(responseMap) {
|
|
19
|
+
return Object.values(responseMap).flatMap(marker => {
|
|
20
|
+
const pluginId = getResponsePluginMarkerId(marker);
|
|
21
|
+
return pluginId ? [pluginId] : [];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function getDefaultSuccessStatus(responseMap) {
|
|
25
|
+
for (const key of Object.keys(responseMap)) {
|
|
26
|
+
const marker = responseMap[Number(key)];
|
|
27
|
+
if (isSuccessMarker(marker)) {
|
|
28
|
+
return Number(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return 200;
|
|
32
|
+
}
|
package/dist/response.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { Promisable } from './common.js';
|
|
2
2
|
import type { RouteRequest } from './types/request.js';
|
|
3
3
|
/** Runtime key carried by response plugin markers. */
|
|
4
|
-
export declare const
|
|
4
|
+
export declare const responsePluginMarker: unique symbol;
|
|
5
5
|
/**
|
|
6
6
|
* Compile-time response marker handled by a client/router response plugin pair.
|
|
7
7
|
*
|
|
8
8
|
* @remarks `TClient` is the value returned by generated client action
|
|
9
9
|
* functions. `TRouter` is the non-`Response` value accepted from route handlers.
|
|
10
|
+
* Plugin markers may be used directly as an action response or as success
|
|
11
|
+
* entries in a status-keyed response map.
|
|
10
12
|
*/
|
|
11
|
-
export type ResponsePluginMarker<TClient, TRouter = TClient, TId extends string = string> = {
|
|
12
|
-
readonly [
|
|
13
|
+
export type ResponsePluginMarker<TClient, TRouter = TClient, TId extends string = string> = Record<number, unknown> & {
|
|
14
|
+
readonly [responsePluginMarker]: {
|
|
13
15
|
readonly id: TId;
|
|
14
16
|
readonly client: TClient;
|
|
15
17
|
readonly router: TRouter;
|
package/dist/response.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Runtime key carried by response plugin markers. */
|
|
2
|
-
export const
|
|
2
|
+
export const responsePluginMarker = Symbol.for('rouzer.response-plugin');
|
|
3
3
|
/** Create a response marker for a response plugin. */
|
|
4
4
|
export function createResponsePluginMarker(id) {
|
|
5
5
|
return {
|
|
6
|
-
[
|
|
6
|
+
[responsePluginMarker]: {
|
|
7
7
|
id,
|
|
8
8
|
client: undefined,
|
|
9
9
|
router: undefined,
|
|
@@ -13,14 +13,12 @@ export function createResponsePluginMarker(id) {
|
|
|
13
13
|
/** Get the response plugin id from a plugin marker, if present. */
|
|
14
14
|
export function getResponsePluginMarkerId(value) {
|
|
15
15
|
return isResponsePluginMarker(value)
|
|
16
|
-
? value[
|
|
16
|
+
? value[responsePluginMarker].id
|
|
17
17
|
: undefined;
|
|
18
18
|
}
|
|
19
19
|
/** Return true when a route response marker is handled by a response plugin. */
|
|
20
20
|
export function isResponsePluginMarker(value) {
|
|
21
|
-
return (typeof value === 'object' &&
|
|
22
|
-
value !== null &&
|
|
23
|
-
responsePluginMarkerSymbol in value);
|
|
21
|
+
return (typeof value === 'object' && value !== null && responsePluginMarker in value);
|
|
24
22
|
}
|
|
25
23
|
/** Create a plugin lookup map and reject duplicate plugin ids. */
|
|
26
24
|
export function createResponsePluginMap(plugins = [], label = 'response') {
|
package/dist/server/router.js
CHANGED
|
@@ -4,6 +4,7 @@ import { chain, MiddlewareChain, } from 'alien-middleware';
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { mapValues } from '../common.js';
|
|
6
6
|
import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
|
|
7
|
+
import { getDefaultSuccessStatus, getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
|
|
7
8
|
export { chain };
|
|
8
9
|
// Internal prototype for the router instance.
|
|
9
10
|
class RouterObject extends MiddlewareChain {
|
|
@@ -109,6 +110,11 @@ class RouterObject extends MiddlewareChain {
|
|
|
109
110
|
return httpClientError(error, 'Invalid request body', config);
|
|
110
111
|
}
|
|
111
112
|
}
|
|
113
|
+
if (isResponseMap(schema.response)) {
|
|
114
|
+
;
|
|
115
|
+
context.error = createResponseHelper(schema.response, request, responsePlugins, true);
|
|
116
|
+
context.success = createResponseHelper(schema.response, request, responsePlugins, false);
|
|
117
|
+
}
|
|
112
118
|
const result = await handler(context);
|
|
113
119
|
addDebugHeaders?.(context, route);
|
|
114
120
|
if (result instanceof Response) {
|
|
@@ -125,6 +131,10 @@ class RouterObject extends MiddlewareChain {
|
|
|
125
131
|
request,
|
|
126
132
|
});
|
|
127
133
|
}
|
|
134
|
+
if (isResponseMap(schema.response)) {
|
|
135
|
+
const status = getDefaultSuccessStatus(schema.response);
|
|
136
|
+
return encodeResponseMapResult(schema.response, status, result, request, responsePlugins);
|
|
137
|
+
}
|
|
128
138
|
return Response.json(result);
|
|
129
139
|
}
|
|
130
140
|
});
|
|
@@ -169,9 +179,13 @@ function flattenRoutes(tree, handlers, prefix, debug) {
|
|
|
169
179
|
}
|
|
170
180
|
function validateRouterResponsePlugins(routes, plugins) {
|
|
171
181
|
for (const route of routes) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
const pluginIds = isResponseMap(route.schema.response)
|
|
183
|
+
? getResponseMapPluginIds(route.schema.response)
|
|
184
|
+
: [getResponsePluginMarkerId(route.schema.response)].filter(pluginId => pluginId !== undefined);
|
|
185
|
+
for (const pluginId of pluginIds) {
|
|
186
|
+
if (!plugins.has(pluginId)) {
|
|
187
|
+
throw missingRouterResponsePlugin(pluginId);
|
|
188
|
+
}
|
|
175
189
|
}
|
|
176
190
|
}
|
|
177
191
|
}
|
|
@@ -278,3 +292,39 @@ function createOriginPattern(origin) {
|
|
|
278
292
|
}
|
|
279
293
|
return new ExactPattern(origin);
|
|
280
294
|
}
|
|
295
|
+
/** Create `ctx.error(status, body)` or `ctx.success(status, body)`. */
|
|
296
|
+
function createResponseHelper(responseMap, request, responsePlugins, error) {
|
|
297
|
+
return (status, body) => {
|
|
298
|
+
const marker = responseMap[status];
|
|
299
|
+
if (!marker || isErrorMarker(marker) !== error) {
|
|
300
|
+
throw new Error(`Undeclared ${error ? 'error' : 'success'} response status: ${status}`);
|
|
301
|
+
}
|
|
302
|
+
return encodeResponseMapResult(responseMap, status, body, request, responsePlugins);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function encodeResponseMapResult(responseMap, status, value, request, responsePlugins) {
|
|
306
|
+
const marker = responseMap[status];
|
|
307
|
+
if (!marker) {
|
|
308
|
+
throw new Error(`Undeclared response status: ${status}`);
|
|
309
|
+
}
|
|
310
|
+
if (isErrorMarker(marker)) {
|
|
311
|
+
return Response.json(value, { status });
|
|
312
|
+
}
|
|
313
|
+
const pluginId = getResponsePluginMarkerId(marker);
|
|
314
|
+
if (!pluginId) {
|
|
315
|
+
return Response.json(value, { status });
|
|
316
|
+
}
|
|
317
|
+
const plugin = responsePlugins.get(pluginId);
|
|
318
|
+
if (!plugin) {
|
|
319
|
+
throw missingRouterResponsePlugin(pluginId);
|
|
320
|
+
}
|
|
321
|
+
const response = await plugin.encode(value, {
|
|
322
|
+
marker: marker,
|
|
323
|
+
request,
|
|
324
|
+
});
|
|
325
|
+
return new Response(response.body, {
|
|
326
|
+
status,
|
|
327
|
+
statusText: response.statusText,
|
|
328
|
+
headers: response.headers,
|
|
329
|
+
});
|
|
330
|
+
}
|
package/dist/type.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { Unchecked } from './common.js';
|
|
1
|
+
import type { Unchecked, UncheckedError } from './common.js';
|
|
2
2
|
/**
|
|
3
3
|
* Create a compile-time-only marker for an action's JSON response payload type.
|
|
4
4
|
*
|
|
5
|
-
* @remarks `$type<T>()` does not
|
|
6
|
-
* server handler return values and client action
|
|
7
|
-
* whose responses are expected to be JSON.
|
|
5
|
+
* @remarks `$type<T>()` does not validate handler return values at the server
|
|
6
|
+
* boundary. It lets Rouzer type server handler return values and client action
|
|
7
|
+
* functions for HTTP actions whose responses are expected to be JSON. Use it
|
|
8
|
+
* directly as `response` for one JSON success shape, or as a success entry in a
|
|
9
|
+
* status-keyed response map. Validate response data where it enters your server
|
|
10
|
+
* or client code when runtime integrity is required.
|
|
8
11
|
*
|
|
9
12
|
* @example
|
|
10
13
|
* ```ts
|
|
@@ -20,3 +23,29 @@ export declare function $type<T>(): Unchecked<T>;
|
|
|
20
23
|
export declare namespace $type {
|
|
21
24
|
var symbol: symbol;
|
|
22
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a compile-time-only marker for a declared error response type.
|
|
28
|
+
*
|
|
29
|
+
* @remarks `$error<T>()` marks a non-success response branch in a status-keyed
|
|
30
|
+
* response map. It is a type contract, not a runtime validator. On the server,
|
|
31
|
+
* handlers use `ctx.error(status, body)` to return declared errors. On the
|
|
32
|
+
* client, declared error responses resolve as `[error, null, status]` tuple
|
|
33
|
+
* entries instead of rejecting the promise.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { $type, $error } from 'rouzer'
|
|
38
|
+
* import * as http from 'rouzer/http'
|
|
39
|
+
*
|
|
40
|
+
* const getUser = http.get('users/:id', {
|
|
41
|
+
* response: {
|
|
42
|
+
* 200: $type<User>(),
|
|
43
|
+
* 404: $error<{ code: string; message: string }>(),
|
|
44
|
+
* },
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function $error<T>(): UncheckedError<T>;
|
|
49
|
+
export declare namespace $error {
|
|
50
|
+
var symbol: symbol;
|
|
51
|
+
}
|
package/dist/type.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Create a compile-time-only marker for an action's JSON response payload type.
|
|
3
3
|
*
|
|
4
|
-
* @remarks `$type<T>()` does not
|
|
5
|
-
* server handler return values and client action
|
|
6
|
-
* whose responses are expected to be JSON.
|
|
4
|
+
* @remarks `$type<T>()` does not validate handler return values at the server
|
|
5
|
+
* boundary. It lets Rouzer type server handler return values and client action
|
|
6
|
+
* functions for HTTP actions whose responses are expected to be JSON. Use it
|
|
7
|
+
* directly as `response` for one JSON success shape, or as a success entry in a
|
|
8
|
+
* status-keyed response map. Validate response data where it enters your server
|
|
9
|
+
* or client code when runtime integrity is required.
|
|
7
10
|
*
|
|
8
11
|
* @example
|
|
9
12
|
* ```ts
|
|
@@ -19,3 +22,29 @@ export function $type() {
|
|
|
19
22
|
return $type.symbol;
|
|
20
23
|
}
|
|
21
24
|
$type.symbol = Symbol();
|
|
25
|
+
/**
|
|
26
|
+
* Create a compile-time-only marker for a declared error response type.
|
|
27
|
+
*
|
|
28
|
+
* @remarks `$error<T>()` marks a non-success response branch in a status-keyed
|
|
29
|
+
* response map. It is a type contract, not a runtime validator. On the server,
|
|
30
|
+
* handlers use `ctx.error(status, body)` to return declared errors. On the
|
|
31
|
+
* client, declared error responses resolve as `[error, null, status]` tuple
|
|
32
|
+
* entries instead of rejecting the promise.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { $type, $error } from 'rouzer'
|
|
37
|
+
* import * as http from 'rouzer/http'
|
|
38
|
+
*
|
|
39
|
+
* const getUser = http.get('users/:id', {
|
|
40
|
+
* response: {
|
|
41
|
+
* 200: $type<User>(),
|
|
42
|
+
* 404: $error<{ code: string; message: string }>(),
|
|
43
|
+
* },
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function $error() {
|
|
48
|
+
return $error.symbol;
|
|
49
|
+
}
|
|
50
|
+
$error.symbol = Symbol();
|
package/dist/types/handler.d.ts
CHANGED
|
@@ -3,11 +3,61 @@ import type { AnyMiddlewareChain, MiddlewareContext } from 'alien-middleware';
|
|
|
3
3
|
import type * as z from 'zod';
|
|
4
4
|
import { Promisable } from '../common.js';
|
|
5
5
|
import type { HttpAction } from '../http.js';
|
|
6
|
-
import type { InferRouteHandlerResult } from './response.js';
|
|
7
|
-
import type { RouteSchema } from './schema.js';
|
|
6
|
+
import type { InferRouteHandlerResult, InferResponseMapErrors, InferResponseMapSuccesses } from './response.js';
|
|
7
|
+
import type { RouteResponseMap, RouteSchema } from './schema.js';
|
|
8
8
|
type RequestContext<TMiddleware extends AnyMiddlewareChain> = MiddlewareContext<TMiddleware>;
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Error response returned by `ctx.error(status, body)` in route handlers.
|
|
11
|
+
*
|
|
12
|
+
* @remarks This is an opaque branded type returned by the error helper. Route
|
|
13
|
+
* handlers may return it to signal a declared error response.
|
|
14
|
+
*/
|
|
15
|
+
export type RouteErrorResponse = Response & {
|
|
16
|
+
__routeError__: true;
|
|
17
|
+
};
|
|
18
|
+
/** Response returned by `ctx.success(status, body)` in route handlers. */
|
|
19
|
+
export type RouteSuccessResponse = Response & {
|
|
20
|
+
__routeSuccess__: true;
|
|
21
|
+
};
|
|
22
|
+
export type RouteRequestHandler<TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult, TErrors = never, TSuccesses = never> = (context: RequestContext<TMiddleware> & TArgs & ([TErrors] extends [never] ? {} : {
|
|
23
|
+
/**
|
|
24
|
+
* Return a declared error response.
|
|
25
|
+
*
|
|
26
|
+
* @remarks Only statuses declared with `$error<T>()` in the response
|
|
27
|
+
* map are accepted.
|
|
28
|
+
*/
|
|
29
|
+
error: <TEntry extends TErrors>(...args: TEntry extends [infer S extends number, infer B] ? [status: S, body: B] : never) => RouteErrorResponse;
|
|
30
|
+
}) & ([TSuccesses] extends [never] ? {} : {
|
|
31
|
+
/**
|
|
32
|
+
* Return a declared success response with an explicit status.
|
|
33
|
+
*
|
|
34
|
+
* @remarks Useful when a response map declares multiple 2xx statuses.
|
|
35
|
+
*/
|
|
36
|
+
success: <TEntry extends TSuccesses>(...args: TEntry extends [infer S extends number, infer B] ? [status: S, body: B] : never) => RouteSuccessResponse;
|
|
37
|
+
})) => Promisable<TResult | Response>;
|
|
38
|
+
export type InferActionHandler<TMiddleware extends AnyMiddlewareChain, TAction extends HttpAction, TPath extends string> = TAction['schema'] extends {
|
|
39
|
+
response: infer R extends RouteResponseMap;
|
|
40
|
+
} ? TAction['method'] extends 'GET' ? RouteRequestHandler<TMiddleware, {
|
|
41
|
+
path: TAction['schema'] extends {
|
|
42
|
+
path: any;
|
|
43
|
+
} ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
|
|
44
|
+
query: TAction['schema'] extends {
|
|
45
|
+
query: any;
|
|
46
|
+
} ? z.infer<TAction['schema']['query']> : undefined;
|
|
47
|
+
headers: TAction['schema'] extends {
|
|
48
|
+
headers: any;
|
|
49
|
+
} ? z.infer<TAction['schema']['headers']> : undefined;
|
|
50
|
+
}, InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>, InferResponseMapErrors<R>, InferResponseMapSuccesses<R>> : RouteRequestHandler<TMiddleware, {
|
|
51
|
+
path: TAction['schema'] extends {
|
|
52
|
+
path: any;
|
|
53
|
+
} ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
|
|
54
|
+
body: TAction['schema'] extends {
|
|
55
|
+
body: any;
|
|
56
|
+
} ? z.infer<TAction['schema']['body']> : undefined;
|
|
57
|
+
headers: TAction['schema'] extends {
|
|
58
|
+
headers: any;
|
|
59
|
+
} ? z.infer<TAction['schema']['headers']> : undefined;
|
|
60
|
+
}, InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>, InferResponseMapErrors<R>, InferResponseMapSuccesses<R>> : TAction['method'] extends 'GET' ? RouteRequestHandler<TMiddleware, {
|
|
11
61
|
path: TAction['schema'] extends {
|
|
12
62
|
path: any;
|
|
13
63
|
} ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
|
package/dist/types/response.d.ts
CHANGED
|
@@ -1,17 +1,57 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Unchecked, UncheckedError } from '../common.js';
|
|
2
|
+
import type { ResponsePluginMarker } from '../response.js';
|
|
3
|
+
import type { RouteResponseMap, RouteSchema } from './schema.js';
|
|
2
4
|
/** `Response` whose `.json()` method resolves to a known payload type. */
|
|
3
5
|
export type RouteResponse<TResult = any> = Response & {
|
|
4
6
|
json(): Promise<TResult>;
|
|
5
7
|
};
|
|
6
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Helper: given a status-keyed response map, produce the discriminated tuple
|
|
10
|
+
* union for the client.
|
|
11
|
+
*
|
|
12
|
+
* Each entry becomes:
|
|
13
|
+
* - `$type<T>()` → `[null, T, Status]`
|
|
14
|
+
* - `$error<T>()` → `[T, null, Status]`
|
|
15
|
+
*/
|
|
16
|
+
type InferResponseMapClient<T extends RouteResponseMap> = {
|
|
17
|
+
[K in keyof T & number]: T[K] extends UncheckedError<infer TError> ? [TError, null, K] : T[K] extends Unchecked<infer TSuccess> ? [null, TSuccess, K] : T[K] extends ResponsePluginMarker<infer TClient, any> ? [null, TClient, K] : never;
|
|
18
|
+
}[keyof T & number];
|
|
19
|
+
/**
|
|
20
|
+
* Infer the generated client action result type from an action schema.
|
|
21
|
+
*
|
|
22
|
+
* @remarks Direct JSON markers infer their payload type, plugin markers infer
|
|
23
|
+
* their client result type, and status-keyed response maps infer a tuple union
|
|
24
|
+
* of `[null, value, status]` success entries and `[error, null, status]` error
|
|
25
|
+
* entries.
|
|
26
|
+
*/
|
|
7
27
|
export type InferRouteResponse<T extends RouteSchema> = T extends {
|
|
8
|
-
response:
|
|
9
|
-
} ? TClient :
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
response: infer R;
|
|
29
|
+
} ? R extends ResponsePluginMarker<infer TClient, any> ? TClient : R extends Unchecked<infer TResponse> ? TResponse : R extends RouteResponseMap ? InferResponseMapClient<R> : void : void;
|
|
30
|
+
/**
|
|
31
|
+
* Helper: given a status-keyed response map, produce the union of handler
|
|
32
|
+
* result types (success values the handler can return directly).
|
|
33
|
+
*/
|
|
34
|
+
type InferResponseMapHandlerResult<T extends RouteResponseMap> = {
|
|
35
|
+
[K in keyof T & number]: T[K] extends Unchecked<infer TSuccess> ? TSuccess : T[K] extends ResponsePluginMarker<any, infer TRouter> ? TRouter : never;
|
|
36
|
+
}[keyof T & number];
|
|
37
|
+
/**
|
|
38
|
+
* Infer the non-`Response` handler result type from an action schema.
|
|
39
|
+
*
|
|
40
|
+
* @remarks For status-keyed response maps, this includes only success result
|
|
41
|
+
* values. Declared error responses are returned with `ctx.error(status, body)`.
|
|
42
|
+
*/
|
|
13
43
|
export type InferRouteHandlerResult<T extends RouteSchema> = T extends {
|
|
14
|
-
response:
|
|
15
|
-
} ? TRouter :
|
|
16
|
-
|
|
17
|
-
|
|
44
|
+
response: infer R;
|
|
45
|
+
} ? R extends ResponsePluginMarker<any, infer TRouter> ? TRouter : R extends Unchecked<infer TResponse> ? TResponse : R extends RouteResponseMap ? InferResponseMapHandlerResult<R> : void : void;
|
|
46
|
+
/**
|
|
47
|
+
* Helper: given a status-keyed response map, extract error entries as a union
|
|
48
|
+
* of `[status, body]` pairs for typing `ctx.error(status, body)`.
|
|
49
|
+
*/
|
|
50
|
+
export type InferResponseMapErrors<T extends RouteResponseMap> = {
|
|
51
|
+
[K in keyof T & number]: T[K] extends UncheckedError<infer TError> ? [K, TError] : never;
|
|
52
|
+
}[keyof T & number];
|
|
53
|
+
/** Extract success entries as a union of `[status, body]` pairs. */
|
|
54
|
+
export type InferResponseMapSuccesses<T extends RouteResponseMap> = {
|
|
55
|
+
[K in keyof T & number]: T[K] extends Unchecked<infer TSuccess> ? [K, TSuccess] : T[K] extends ResponsePluginMarker<any, infer TRouter> ? [K, TRouter] : never;
|
|
56
|
+
}[keyof T & number];
|
|
57
|
+
export {};
|
package/dist/types/schema.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from 'zod';
|
|
2
|
-
import type { Unchecked } from '../common.js';
|
|
2
|
+
import type { Unchecked, UncheckedError } from '../common.js';
|
|
3
3
|
import type { ResponsePluginMarker } from '../response.js';
|
|
4
4
|
/**
|
|
5
5
|
* Compile-time-only marker used by `$type<T>()` for unchecked JSON response
|
|
@@ -8,10 +8,21 @@ import type { ResponsePluginMarker } from '../response.js';
|
|
|
8
8
|
* @remarks Application code should usually call `$type<T>()` instead of naming
|
|
9
9
|
* this marker directly.
|
|
10
10
|
*/
|
|
11
|
-
export type { Unchecked };
|
|
12
|
-
|
|
11
|
+
export type { ResponsePluginMarker, Unchecked, UncheckedError };
|
|
12
|
+
/** Single response marker accepted by status-keyed response maps. */
|
|
13
|
+
export type RouteResponseMarker = Unchecked<any> | UncheckedError<any> | ResponsePluginMarker<any, any>;
|
|
14
|
+
/**
|
|
15
|
+
* Status-keyed response map for declaring multiple response types.
|
|
16
|
+
*
|
|
17
|
+
* @remarks Numeric keys are HTTP status codes. Use `$type<T>()` or a response
|
|
18
|
+
* plugin marker for success responses and `$error<T>()` for declared error
|
|
19
|
+
* JSON responses.
|
|
20
|
+
*/
|
|
21
|
+
export type RouteResponseMap = {
|
|
22
|
+
[status: number]: RouteResponseMarker;
|
|
23
|
+
};
|
|
13
24
|
/** Response marker accepted by HTTP action schemas. */
|
|
14
|
-
export type RouteResponseSchema = Unchecked<any> | ResponsePluginMarker<any, any
|
|
25
|
+
export type RouteResponseSchema = Unchecked<any> | ResponsePluginMarker<any, any> | RouteResponseMap;
|
|
15
26
|
/** Schema shape for `GET` route methods. */
|
|
16
27
|
export type QueryRouteSchema = {
|
|
17
28
|
/** Optional Zod object used to validate path params. */
|
package/docs/context.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Rouzer is for applications that want one TypeScript HTTP route tree to drive
|
|
4
4
|
both the server and the client that calls it. A route tree combines URL
|
|
5
|
-
patterns, named actions, HTTP method schemas, and optional compile-time
|
|
6
|
-
|
|
5
|
+
patterns, named actions, HTTP method schemas, and optional compile-time success,
|
|
6
|
+
error, or plugin response types.
|
|
7
7
|
|
|
8
8
|
## When to use Rouzer
|
|
9
9
|
|
|
@@ -17,9 +17,12 @@ Use Rouzer when:
|
|
|
17
17
|
- generated clients should stay close to route definitions instead of being
|
|
18
18
|
produced by a separate OpenAPI build step
|
|
19
19
|
|
|
20
|
-
Rouzer is not a response
|
|
20
|
+
Rouzer is not a server response validator, an OpenAPI generator, or a complete
|
|
21
21
|
server framework. It focuses on typed route contracts, request validation,
|
|
22
|
-
routing, and a small client wrapper.
|
|
22
|
+
routing, and a small client wrapper. Response markers are type contracts; if
|
|
23
|
+
response data comes from an untrusted source, validate it where it enters your
|
|
24
|
+
server or client code instead of relying on the router to re-check handler
|
|
25
|
+
returns.
|
|
23
26
|
|
|
24
27
|
## Core abstractions
|
|
25
28
|
|
|
@@ -92,11 +95,47 @@ The HTTP action API models explicit operations. It does not expose the old
|
|
|
92
95
|
method-map `ALL` fallback route shape; declare the concrete methods your client
|
|
93
96
|
and server support.
|
|
94
97
|
|
|
95
|
-
###
|
|
98
|
+
### Response markers and maps
|
|
96
99
|
|
|
97
|
-
`response: $type<T>()` is a TypeScript-only marker for JSON
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
`response: $type<T>()` is a TypeScript-only marker for JSON success payloads. It
|
|
101
|
+
tells handlers and client action functions what payload type to expect, but
|
|
102
|
+
Rouzer does not validate handler return values at the server boundary. Validate
|
|
103
|
+
response data where it enters your system, such as an external API client,
|
|
104
|
+
database decoder, or UI/client boundary, when runtime integrity is required.
|
|
105
|
+
|
|
106
|
+
Use a status-keyed response map when callers need to branch on declared statuses:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { $error, $type } from 'rouzer'
|
|
110
|
+
import * as http from 'rouzer/http'
|
|
111
|
+
|
|
112
|
+
type User = { id: string; name: string }
|
|
113
|
+
type NotFound = { code: 'NOT_FOUND'; message: string }
|
|
114
|
+
|
|
115
|
+
export const getUser = http.get('users/:id', {
|
|
116
|
+
response: {
|
|
117
|
+
200: $type<User>(),
|
|
118
|
+
201: $type<User>(),
|
|
119
|
+
404: $error<NotFound>(),
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Success entries use `$type<T>()` or a response plugin marker. Error entries use
|
|
125
|
+
`$error<T>()` and are encoded as JSON. Generated client action functions resolve
|
|
126
|
+
declared statuses as tuples:
|
|
127
|
+
|
|
128
|
+
- success: `[null, value, status]`
|
|
129
|
+
- error: `[error, null, status]`
|
|
130
|
+
|
|
131
|
+
Declared error statuses do not reject the client promise. Undeclared statuses
|
|
132
|
+
still go through `onJsonError` or throw the default error.
|
|
133
|
+
|
|
134
|
+
Handlers for response-map actions may return the default success value directly,
|
|
135
|
+
use `ctx.success(status, body)` to choose a declared success status, or use
|
|
136
|
+
`ctx.error(status, body)` to return a declared error status. The `ctx.error` and
|
|
137
|
+
`ctx.success` helpers only accept statuses and bodies declared in the response
|
|
138
|
+
map.
|
|
100
139
|
|
|
101
140
|
`response: ndjson.$type<T>()` is a TypeScript-only marker for newline-delimited
|
|
102
141
|
JSON response streams from the `rouzer/ndjson` subpath. Register
|
|
@@ -109,8 +148,8 @@ response body. Streamed items are parsed as JSON but are not validated against a
|
|
|
109
148
|
Zod schema.
|
|
110
149
|
|
|
111
150
|
Actions without a `response` marker return a raw `Response` from client action
|
|
112
|
-
functions. Actions with `response: $type<T>()`
|
|
113
|
-
|
|
151
|
+
functions. Actions with `response: $type<T>()` return parsed JSON typed as `T`.
|
|
152
|
+
Actions with a response map return the tuple union described by that map.
|
|
114
153
|
|
|
115
154
|
### Response plugins
|
|
116
155
|
|
|
@@ -121,10 +160,11 @@ matching runtime plugins. For NDJSON, those are `ndjson.$type<T>()`,
|
|
|
121
160
|
|
|
122
161
|
The router plugin encodes non-`Response` handler results into an HTTP `Response`.
|
|
123
162
|
The client plugin decodes successful HTTP responses for generated client action
|
|
124
|
-
functions.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
163
|
+
functions. Plugin markers can also be success entries in a status-keyed response
|
|
164
|
+
map. Rouzer validates plugin registration when routes are attached to a router or
|
|
165
|
+
client, so routes that use an unregistered response marker fail fast instead of
|
|
166
|
+
falling back to JSON. Response plugins do not automatically validate response
|
|
167
|
+
payloads unless the plugin itself implements validation.
|
|
128
168
|
|
|
129
169
|
### Router
|
|
130
170
|
|
|
@@ -157,6 +197,8 @@ Handlers receive a context typed from middleware plus the action schema:
|
|
|
157
197
|
- `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
|
|
158
198
|
- mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
|
|
159
199
|
- handlers may return a plain JSON-serializable value or a `Response`
|
|
200
|
+
- response-map handlers can return a default success value directly or use
|
|
201
|
+
`ctx.success(status, body)` and `ctx.error(status, body)`
|
|
160
202
|
- `ndjson.$type<T>()` handlers return an `Iterable<T>` or `AsyncIterable<T>`
|
|
161
203
|
unless they return a custom `Response`
|
|
162
204
|
- plain values are returned with `Response.json(value)`
|
|
@@ -175,6 +217,8 @@ requests with an `Origin` header.
|
|
|
175
217
|
request factory contains the full path you want to call
|
|
176
218
|
- `client.json(action.request(args))` for parsed JSON and default non-2xx
|
|
177
219
|
throwing
|
|
220
|
+
- response-map support for generated client action functions, returning
|
|
221
|
+
`[error, value, status]` tuples for declared statuses
|
|
178
222
|
- response plugin support for generated client action functions, such as
|
|
179
223
|
`ndjson.clientPlugin` for NDJSON response streams
|
|
180
224
|
- a client tree that mirrors `routes`, with action functions such as
|
|
@@ -205,9 +249,9 @@ runtimes.
|
|
|
205
249
|
`fetch`.
|
|
206
250
|
5. The router matches the request, validates the matched inputs, and calls the
|
|
207
251
|
handler.
|
|
208
|
-
6. Plain handler results become JSON responses,
|
|
209
|
-
plugin-encoded responses, and
|
|
210
|
-
unchanged.
|
|
252
|
+
6. Plain handler results become JSON responses, response-map helpers choose
|
|
253
|
+
declared statuses, plugin handler results become plugin-encoded responses, and
|
|
254
|
+
explicit `Response` objects pass through unchanged.
|
|
211
255
|
|
|
212
256
|
On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
|
|
213
257
|
coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
|
|
@@ -247,9 +291,64 @@ const json = await client.json(
|
|
|
247
291
|
)
|
|
248
292
|
```
|
|
249
293
|
|
|
250
|
-
Response plugins are applied by generated client action
|
|
251
|
-
calls to plugin-backed routes, use
|
|
252
|
-
|
|
294
|
+
Response maps and response plugins are applied by generated client action
|
|
295
|
+
functions. For longhand calls to mapped or plugin-backed routes, use
|
|
296
|
+
`client.request(...)` for the raw `Response` and decode the response yourself.
|
|
297
|
+
|
|
298
|
+
### Handle declared error responses
|
|
299
|
+
|
|
300
|
+
Use `$error<T>()` inside a response map when an error status is part of the route
|
|
301
|
+
contract:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import { $error, $type, createClient, createRouter } from 'rouzer'
|
|
305
|
+
import * as http from 'rouzer/http'
|
|
306
|
+
|
|
307
|
+
type User = { id: string; name: string }
|
|
308
|
+
type NotFound = { code: 'NOT_FOUND'; message: string }
|
|
309
|
+
|
|
310
|
+
export const getUser = http.get('users/:id', {
|
|
311
|
+
response: {
|
|
312
|
+
200: $type<User>(),
|
|
313
|
+
404: $error<NotFound>(),
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
export const routes = { getUser }
|
|
317
|
+
|
|
318
|
+
createRouter().use(routes, {
|
|
319
|
+
getUser(ctx) {
|
|
320
|
+
if (ctx.path.id === 'missing') {
|
|
321
|
+
return ctx.error(404, {
|
|
322
|
+
code: 'NOT_FOUND',
|
|
323
|
+
message: 'User not found',
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
return { id: ctx.path.id, name: 'Ada' }
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const client = createClient({
|
|
331
|
+
baseURL: 'https://example.com/api/',
|
|
332
|
+
routes,
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const [error, user, status] = await client.getUser({
|
|
336
|
+
path: { id: 'missing' },
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
if (status === 404) {
|
|
340
|
+
console.log(error.message)
|
|
341
|
+
} else {
|
|
342
|
+
console.log(user.name)
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
A complete runnable version lives in
|
|
347
|
+
[`examples/error-responses.ts`](../examples/error-responses.ts).
|
|
348
|
+
|
|
349
|
+
When a response map declares multiple success statuses, return a plain value for
|
|
350
|
+
the default success status or use `ctx.success(status, body)` to choose a
|
|
351
|
+
specific declared success status.
|
|
253
352
|
|
|
254
353
|
### Stream newline-delimited JSON
|
|
255
354
|
|
|
@@ -322,8 +421,8 @@ custom headers. Return a plain value for the default `Response.json(value)` path
|
|
|
322
421
|
### Customize JSON errors
|
|
323
422
|
|
|
324
423
|
By default, `client.json(...)` and generated client action functions throw for
|
|
325
|
-
non-2xx responses
|
|
326
|
-
the thrown `Error`.
|
|
424
|
+
non-2xx responses that are not declared in a response map. If the response body
|
|
425
|
+
is JSON, its properties are copied onto the thrown `Error`.
|
|
327
426
|
|
|
328
427
|
`onJsonError` can override that behavior. Its return value is returned from the
|
|
329
428
|
response helper as-is; Rouzer does not automatically parse a returned `Response`
|
|
@@ -390,6 +489,8 @@ await client.profiles.update({
|
|
|
390
489
|
only when string params are sufficient.
|
|
391
490
|
- Use `response: $type<T>()` for JSON endpoints that should have typed client
|
|
392
491
|
action functions.
|
|
492
|
+
- Use response maps with `$error<T>()` when callers should handle declared error
|
|
493
|
+
statuses as typed data instead of exceptions.
|
|
393
494
|
- Use `response: ndjson.$type<T>()` plus `ndjson.routerPlugin` and
|
|
394
495
|
`ndjson.clientPlugin` for response streams where each line is a JSON value and
|
|
395
496
|
the client should consume an `AsyncIterable<T>`.
|
|
@@ -400,10 +501,13 @@ await client.profiles.update({
|
|
|
400
501
|
|
|
401
502
|
## Constraints and gotchas
|
|
402
503
|
|
|
403
|
-
- `$type<T>()
|
|
404
|
-
|
|
504
|
+
- `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are compile-time-only type
|
|
505
|
+
contracts. Rouzer does not re-validate handler return values at the server
|
|
506
|
+
boundary.
|
|
405
507
|
- NDJSON support is for response streams; request bodies still use the existing
|
|
406
508
|
JSON body schema path.
|
|
509
|
+
- Declared `$error<T>()` responses are JSON responses. Use a custom `Response`
|
|
510
|
+
for non-JSON error payloads.
|
|
407
511
|
- Routes that use a response plugin fail fast if the matching client or router
|
|
408
512
|
plugin is not registered.
|
|
409
513
|
- Pathname route patterns expect an absolute client `baseURL`.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { HattipHandler } from '@hattip/core'
|
|
2
|
+
import { $error, $type, createClient, createRouter } from 'rouzer'
|
|
3
|
+
import * as http from 'rouzer/http'
|
|
4
|
+
|
|
5
|
+
type User = {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type AuthError = {
|
|
11
|
+
code: 'UNAUTHORIZED'
|
|
12
|
+
message: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type NotFoundError = {
|
|
16
|
+
code: 'NOT_FOUND'
|
|
17
|
+
message: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const getUser = http.get('users/:id', {
|
|
21
|
+
response: {
|
|
22
|
+
200: $type<User>(),
|
|
23
|
+
201: $type<User>(),
|
|
24
|
+
401: $error<AuthError>(),
|
|
25
|
+
404: $error<NotFoundError>(),
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const routes = { getUser }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tiny Hattip adapter used only to keep this example self-contained. Real apps
|
|
33
|
+
* mount the handler with a Hattip adapter for their runtime.
|
|
34
|
+
*/
|
|
35
|
+
function createLocalFetch(handler: HattipHandler): typeof fetch {
|
|
36
|
+
return async (input, init) => {
|
|
37
|
+
const request = new Request(input, init)
|
|
38
|
+
const response = await handler({
|
|
39
|
+
request,
|
|
40
|
+
ip: '127.0.0.1',
|
|
41
|
+
platform: undefined,
|
|
42
|
+
env() {
|
|
43
|
+
return undefined
|
|
44
|
+
},
|
|
45
|
+
passThrough() {},
|
|
46
|
+
waitUntil(promise) {
|
|
47
|
+
void promise
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return response ?? new Response(null, { status: 404 })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runErrorResponsesExample() {
|
|
56
|
+
const users = new Map([['42', { id: '42', name: 'Ada' }]])
|
|
57
|
+
|
|
58
|
+
const handler = createRouter({ basePath: 'api/' }).use(routes, {
|
|
59
|
+
getUser(ctx) {
|
|
60
|
+
if (ctx.path.id === 'unauthorized') {
|
|
61
|
+
return ctx.error(401, {
|
|
62
|
+
code: 'UNAUTHORIZED',
|
|
63
|
+
message: 'Login required',
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ctx.path.id === 'created') {
|
|
68
|
+
return ctx.success(201, {
|
|
69
|
+
id: 'created',
|
|
70
|
+
name: 'Grace',
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const user = users.get(ctx.path.id)
|
|
75
|
+
if (!user) {
|
|
76
|
+
return ctx.error(404, {
|
|
77
|
+
code: 'NOT_FOUND',
|
|
78
|
+
message: 'User not found',
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return user
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const client = createClient({
|
|
87
|
+
baseURL: 'https://example.test/api/',
|
|
88
|
+
routes,
|
|
89
|
+
fetch: createLocalFetch(handler),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const found = await client.getUser({ path: { id: '42' } })
|
|
93
|
+
const created = await client.getUser({ path: { id: 'created' } })
|
|
94
|
+
const missing = await client.getUser({ path: { id: 'missing' } })
|
|
95
|
+
const unauthorized = await client.getUser({ path: { id: 'unauthorized' } })
|
|
96
|
+
|
|
97
|
+
return { found, created, missing, unauthorized }
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rouzer",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
],
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "rm -rf dist && tsgo -b tsconfig.json",
|
|
55
|
+
"format": "prettier --write src test",
|
|
55
56
|
"test": "vitest run"
|
|
56
57
|
}
|
|
57
58
|
}
|