diff --git a/README.md b/README.md index c43aaed..2a997d4 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ and Zod validation between a Hattip-compatible server and a typed fetch client. ## What it does A Rouzer HTTP route tree defines URL patterns, named actions, method schemas, and -optional JSON or newline-delimited JSON response types once, then reuses that -contract to: +optional JSON, error, or newline-delimited JSON response types once, then reuses +that contract to: - validate client arguments before `fetch` - match and validate server requests before handlers run - type handler context from path, query/body, headers, and middleware - attach typed client action functions such as `client.profiles.get(...)` -- parse typed JSON responses and typed NDJSON response streams +- parse typed JSON responses, declared error responses, and NDJSON streams Rouzer optimizes for shared TypeScript route modules over language-agnostic API schemas or generated SDKs. @@ -24,6 +24,8 @@ Use Rouzer if: - your server and client can import the same TypeScript route tree - you want Zod request validation on both sides of an HTTP boundary +- response data is validated at data/client boundaries, not by re-checking every + handler return - a Hattip-compatible handler fits your server runtime - you prefer named resource/action functions over a generated client class @@ -31,8 +33,8 @@ Consider something else if: - you need OpenAPI-first workflows, schema files, or generated clients for other languages -- you need runtime response-body validation; `response: $type()` and - `response: ndjson.$type()` are compile-time only +- you want the router to validate every response body at the server boundary; + `$type()`, `$error()`, and `ndjson.$type()` are type contracts - you want a framework that owns controllers, data loading, rendering, and deployment adapters - 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 subpath: ```ts -import { $type, chain, createClient, createRouter } from 'rouzer' +import { $error, $type, chain, createClient, createRouter } from 'rouzer' import * as http from 'rouzer/http' ``` @@ -103,6 +105,49 @@ const { message } = await client.hello({ route arguments before `fetch`; server handlers validate matched path, query, headers, and JSON bodies before your handler runs. +### Typed status responses + +Use a response map when client code needs declared error statuses as data instead +of exceptions. + +```ts +import { $error, $type, createClient, createRouter } from 'rouzer' +import * as http from 'rouzer/http' + +type User = { id: string; name: string } +type NotFound = { code: 'NOT_FOUND'; message: string } + +export const getUser = http.get('users/:id', { + response: { + 200: $type(), + 404: $error(), + }, +}) +export const routes = { getUser } + +createRouter().use(routes, { + getUser(ctx) { + if (ctx.path.id === 'missing') { + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'User not found', + }) + } + return { id: ctx.path.id, name: 'Ada' } + }, +}) + +const client = createClient({ + baseURL: 'https://example.com/api/', + routes, +}) + +const [error, user, status] = await client.getUser({ path: { id: '42' } }) +``` + +Success entries resolve as `[null, value, status]`; declared error entries +resolve as `[error, null, status]`. + ### NDJSON response streams Use `response: ndjson.$type()` for endpoints that stream @@ -141,6 +186,7 @@ for await (const event of await client.events()) { - [Concepts, API selection, and v2->v3 migration notes](docs/context.md) - [Runnable shared-route example](examples/basic-usage.ts) +- [Runnable typed error response example](examples/error-responses.ts) - [Runnable NDJSON response-stream example](examples/ndjson-stream.ts) - Generated declarations in the published package provide the exact signatures for every public export, including the `rouzer/http` and `rouzer/ndjson` diff --git a/docs/context.md b/docs/context.md index 6d1f00f..ac10842 100644 --- a/docs/context.md +++ b/docs/context.md @@ -2,8 +2,8 @@ Rouzer is for applications that want one TypeScript HTTP route tree to drive both the server and the client that calls it. A route tree combines URL -patterns, named actions, HTTP method schemas, and optional compile-time JSON or -NDJSON response types. +patterns, named actions, HTTP method schemas, and optional compile-time success, +error, or plugin response types. ## When to use Rouzer @@ -17,9 +17,12 @@ Use Rouzer when: - generated clients should stay close to route definitions instead of being produced by a separate OpenAPI build step -Rouzer is not a response validation library, an OpenAPI generator, or a complete +Rouzer is not a server response validator, an OpenAPI generator, or a complete server framework. It focuses on typed route contracts, request validation, -routing, and a small client wrapper. +routing, and a small client wrapper. Response markers are type contracts; if +response data comes from an untrusted source, validate it where it enters your +server or client code instead of relying on the router to re-check handler +returns. ## Core abstractions @@ -92,11 +95,47 @@ The HTTP action API models explicit operations. It does not expose the old method-map `ALL` fallback route shape; declare the concrete methods your client and server support. -### `$type()` and `ndjson.$type()` +### Response markers and maps -`response: $type()` is a TypeScript-only marker for JSON response payloads. -It tells handlers and client action functions what response payload type to -expect, but Rouzer does not validate response bodies at runtime. +`response: $type()` is a TypeScript-only marker for JSON success payloads. It +tells handlers and client action functions what payload type to expect, but +Rouzer does not validate handler return values at the server boundary. Validate +response data where it enters your system, such as an external API client, +database decoder, or UI/client boundary, when runtime integrity is required. + +Use a status-keyed response map when callers need to branch on declared statuses: + +```ts +import { $error, $type } from 'rouzer' +import * as http from 'rouzer/http' + +type User = { id: string; name: string } +type NotFound = { code: 'NOT_FOUND'; message: string } + +export const getUser = http.get('users/:id', { + response: { + 200: $type(), + 201: $type(), + 404: $error(), + }, +}) +``` + +Success entries use `$type()` or a response plugin marker. Error entries use +`$error()` and are encoded as JSON. Generated client action functions resolve +declared statuses as tuples: + +- success: `[null, value, status]` +- error: `[error, null, status]` + +Declared error statuses do not reject the client promise. Undeclared statuses +still go through `onJsonError` or throw the default error. + +Handlers for response-map actions may return the default success value directly, +use `ctx.success(status, body)` to choose a declared success status, or use +`ctx.error(status, body)` to return a declared error status. The `ctx.error` and +`ctx.success` helpers only accept statuses and bodies declared in the response +map. `response: ndjson.$type()` is a TypeScript-only marker for newline-delimited 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 Zod schema. Actions without a `response` marker return a raw `Response` from client action -functions. Actions with `response: $type()` use `client.json(...)` under the -hood and return parsed JSON typed as `T`. +functions. Actions with `response: $type()` return parsed JSON typed as `T`. +Actions with a response map return the tuple union described by that map. ### Response plugins @@ -121,10 +160,11 @@ matching runtime plugins. For NDJSON, those are `ndjson.$type()`, The router plugin encodes non-`Response` handler results into an HTTP `Response`. The client plugin decodes successful HTTP responses for generated client action -functions. Rouzer validates plugin registration when routes are attached to a -router or client, so routes that use an unregistered response marker fail fast -instead of falling back to JSON. Response plugins do not automatically validate -response payloads unless the plugin itself implements validation. +functions. Plugin markers can also be success entries in a status-keyed response +map. Rouzer validates plugin registration when routes are attached to a router or +client, so routes that use an unregistered response marker fail fast instead of +falling back to JSON. Response plugins do not automatically validate response +payloads unless the plugin itself implements validation. ### Router @@ -157,6 +197,8 @@ Handlers receive a context typed from middleware plus the action schema: - `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers` - mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers` - handlers may return a plain JSON-serializable value or a `Response` +- response-map handlers can return a default success value directly or use + `ctx.success(status, body)` and `ctx.error(status, body)` - `ndjson.$type()` handlers return an `Iterable` or `AsyncIterable` unless they return a custom `Response` - plain values are returned with `Response.json(value)` @@ -175,6 +217,8 @@ requests with an `Origin` header. request factory contains the full path you want to call - `client.json(action.request(args))` for parsed JSON and default non-2xx throwing +- response-map support for generated client action functions, returning + `[error, value, status]` tuples for declared statuses - response plugin support for generated client action functions, such as `ndjson.clientPlugin` for NDJSON response streams - a client tree that mirrors `routes`, with action functions such as @@ -205,9 +249,9 @@ runtimes. `fetch`. 5. The router matches the request, validates the matched inputs, and calls the handler. -6. Plain handler results become JSON responses, plugin handler results become - plugin-encoded responses, and explicit `Response` objects pass through - unchanged. +6. Plain handler results become JSON responses, response-map helpers choose + declared statuses, plugin handler results become plugin-encoded responses, and + explicit `Response` objects pass through unchanged. On the server, `path`, `query`, and `headers` values originate as strings. Rouzer coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from @@ -247,9 +291,64 @@ const json = await client.json( ) ``` -Response plugins are applied by generated client action functions. For longhand -calls to plugin-backed routes, use `client.request(...)` for the raw `Response` -and call the plugin subpath's decoder yourself. +Response maps and response plugins are applied by generated client action +functions. For longhand calls to mapped or plugin-backed routes, use +`client.request(...)` for the raw `Response` and decode the response yourself. + +### Handle declared error responses + +Use `$error()` inside a response map when an error status is part of the route +contract: + +```ts +import { $error, $type, createClient, createRouter } from 'rouzer' +import * as http from 'rouzer/http' + +type User = { id: string; name: string } +type NotFound = { code: 'NOT_FOUND'; message: string } + +export const getUser = http.get('users/:id', { + response: { + 200: $type(), + 404: $error(), + }, +}) +export const routes = { getUser } + +createRouter().use(routes, { + getUser(ctx) { + if (ctx.path.id === 'missing') { + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'User not found', + }) + } + return { id: ctx.path.id, name: 'Ada' } + }, +}) + +const client = createClient({ + baseURL: 'https://example.com/api/', + routes, +}) + +const [error, user, status] = await client.getUser({ + path: { id: 'missing' }, +}) + +if (status === 404) { + console.log(error.message) +} else { + console.log(user.name) +} +``` + +A complete runnable version lives in +[`examples/error-responses.ts`](../examples/error-responses.ts). + +When a response map declares multiple success statuses, return a plain value for +the default success status or use `ctx.success(status, body)` to choose a +specific declared success status. ### Stream newline-delimited JSON @@ -322,8 +421,8 @@ custom headers. Return a plain value for the default `Response.json(value)` path ### Customize JSON errors By default, `client.json(...)` and generated client action functions throw for -non-2xx responses. If the response body is JSON, its properties are copied onto -the thrown `Error`. +non-2xx responses that are not declared in a response map. If the response body +is JSON, its properties are copied onto the thrown `Error`. `onJsonError` can override that behavior. Its return value is returned from the response helper as-is; Rouzer does not automatically parse a returned `Response` @@ -390,6 +489,8 @@ await client.profiles.update({ only when string params are sufficient. - Use `response: $type()` for JSON endpoints that should have typed client action functions. +- Use response maps with `$error()` when callers should handle declared error + statuses as typed data instead of exceptions. - Use `response: ndjson.$type()` plus `ndjson.routerPlugin` and `ndjson.clientPlugin` for response streams where each line is a JSON value and the client should consume an `AsyncIterable`. @@ -400,10 +501,13 @@ await client.profiles.update({ ## Constraints and gotchas -- `$type()` and `ndjson.$type()` are compile-time only and do not validate - response payloads or streamed items. +- `$type()`, `$error()`, and `ndjson.$type()` are compile-time-only type + contracts. Rouzer does not re-validate handler return values at the server + boundary. - NDJSON support is for response streams; request bodies still use the existing JSON body schema path. +- Declared `$error()` responses are JSON responses. Use a custom `Response` + for non-JSON error payloads. - Routes that use a response plugin fail fast if the matching client or router plugin is not registered. - Pathname route patterns expect an absolute client `baseURL`. diff --git a/examples/error-responses.ts b/examples/error-responses.ts new file mode 100644 index 0000000..e8df688 --- /dev/null +++ b/examples/error-responses.ts @@ -0,0 +1,98 @@ +import type { HattipHandler } from '@hattip/core' +import { $error, $type, createClient, createRouter } from 'rouzer' +import * as http from 'rouzer/http' + +type User = { + id: string + name: string +} + +type AuthError = { + code: 'UNAUTHORIZED' + message: string +} + +type NotFoundError = { + code: 'NOT_FOUND' + message: string +} + +export const getUser = http.get('users/:id', { + response: { + 200: $type(), + 201: $type(), + 401: $error(), + 404: $error(), + }, +}) + +export const routes = { getUser } + +/** + * Tiny Hattip adapter used only to keep this example self-contained. Real apps + * mount the handler with a Hattip adapter for their runtime. + */ +function createLocalFetch(handler: HattipHandler): typeof fetch { + return async (input, init) => { + const request = new Request(input, init) + const response = await handler({ + request, + ip: '127.0.0.1', + platform: undefined, + env() { + return undefined + }, + passThrough() {}, + waitUntil(promise) { + void promise + }, + }) + + return response ?? new Response(null, { status: 404 }) + } +} + +export async function runErrorResponsesExample() { + const users = new Map([['42', { id: '42', name: 'Ada' }]]) + + const handler = createRouter({ basePath: 'api/' }).use(routes, { + getUser(ctx) { + if (ctx.path.id === 'unauthorized') { + return ctx.error(401, { + code: 'UNAUTHORIZED', + message: 'Login required', + }) + } + + if (ctx.path.id === 'created') { + return ctx.success(201, { + id: 'created', + name: 'Grace', + }) + } + + const user = users.get(ctx.path.id) + if (!user) { + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'User not found', + }) + } + + return user + }, + }) + + const client = createClient({ + baseURL: 'https://example.test/api/', + routes, + fetch: createLocalFetch(handler), + }) + + const found = await client.getUser({ path: { id: '42' } }) + const created = await client.getUser({ path: { id: 'created' } }) + const missing = await client.getUser({ path: { id: 'missing' } }) + const unauthorized = await client.getUser({ path: { id: 'unauthorized' } }) + + return { found, created, missing, unauthorized } +} diff --git a/package.json b/package.json index 6dd7830..3c10dcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rouzer", - "version": "3.1.0", + "version": "3.2.0", "type": "module", "exports": { ".": { @@ -18,6 +18,7 @@ }, "scripts": { "build": "rm -rf dist && tsgo -b tsconfig.json", + "format": "prettier --write src test", "test": "vitest run", "prepublishOnly": "pnpm build" }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16bd489..baf7cc8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,5 @@ minimumReleaseAge: 10080 minimumReleaseAgeExclude: - '@remix-run/route-pattern' - alien-middleware +allowBuilds: + esbuild: true diff --git a/src/client/index.ts b/src/client/index.ts index 5d0a8bc..155b7fe 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -8,6 +8,11 @@ import { type ClientResponsePlugin, type ResponsePluginMarker, } from '../response.js' +import { + getResponseMapPluginIds, + isErrorMarker, + isResponseMap, +} from '../response-map.js' import type { RouteArgs } from '../types/args.js' import type { RouteRequest } from '../types/request.js' import type { InferRouteResponse } from '../types/response.js' @@ -140,18 +145,49 @@ export function createClient< props: T ): Promise { const httpResponse = await request(props) + const responseSchema = props.schema.response + + // Handle status-keyed response maps + if (isResponseMap(responseSchema)) { + const status = httpResponse.status + if (status in responseSchema) { + const marker = responseSchema[status] + if (isErrorMarker(marker)) { + return [await httpResponse.json(), null, status] as T['$result'] + } + const pluginId = getResponsePluginMarkerId(marker) + if (pluginId) { + const plugin = responsePlugins.get(pluginId) + if (!plugin) { + throw missingClientResponsePlugin(pluginId) + } + return [ + null, + await plugin.decode(httpResponse, { + marker: marker as ResponsePluginMarker, + request: props, + }), + status, + ] as T['$result'] + } + return [null, await httpResponse.json(), status] as T['$result'] + } + // Undeclared status — reject + return handleResponseError(httpResponse, props) + } + if (!httpResponse.ok) { return handleResponseError(httpResponse, props) } - const pluginId = getResponsePluginMarkerId(props.schema.response) + const pluginId = getResponsePluginMarkerId(responseSchema) if (pluginId) { const plugin = responsePlugins.get(pluginId) if (!plugin) { throw missingClientResponsePlugin(pluginId) } return plugin.decode(httpResponse, { - marker: props.schema.response as ResponsePluginMarker, + marker: responseSchema as unknown as ResponsePluginMarker, request: props, }) as T['$result'] } @@ -205,8 +241,10 @@ export type ClientTree = { * Client action function attached for each HTTP action leaf. * * @remarks Actions whose schema has `response: $type()` return parsed JSON - * as `T`. Actions whose schema has a plugin response marker return the plugin's - * client result type. Actions without a response marker return the raw + * as `T`. Actions whose schema has a status-keyed response map return a tuple + * union of `[null, value, status]` success entries and `[error, null, status]` + * error entries. Actions whose schema has a plugin response marker return the + * plugin's client result type. Actions without a response marker return the raw * `Response`. */ export type RouteFunction = ( @@ -263,9 +301,15 @@ function validateClientResponsePlugins( if (node.kind === 'resource') { validateClientResponsePlugins(node.children, plugins) } else { - const pluginId = getResponsePluginMarkerId(node.schema.response) - if (pluginId && !plugins.has(pluginId)) { - throw missingClientResponsePlugin(pluginId) + const pluginIds = isResponseMap(node.schema.response) + ? getResponseMapPluginIds(node.schema.response) + : [getResponsePluginMarkerId(node.schema.response)].filter( + pluginId => pluginId !== undefined + ) + for (const pluginId of pluginIds) { + if (!plugins.has(pluginId)) { + throw missingClientResponsePlugin(pluginId) + } } } } diff --git a/src/common.ts b/src/common.ts index 5450f2a..9e0ffbf 100644 --- a/src/common.ts +++ b/src/common.ts @@ -7,7 +7,16 @@ export type Promisable = T | Promise * @remarks Consumers usually use `$type()` instead of constructing this type * directly. */ -export type Unchecked = { __unchecked__: T } +export type Unchecked = Record & { __unchecked__: T } + +/** + * Compile-time-only marker used by `$error()` to carry a declared error + * response type through route declarations. + * + * @remarks Consumers usually use `$error()` instead of constructing this + * type directly. + */ +export type UncheckedError = { __uncheckedError__: T } /** * Map over all the keys to create a new object. diff --git a/src/http.ts b/src/http.ts index 8fcf3e6..dbb5065 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,9 +1,6 @@ import { RoutePattern } from '@remix-run/route-pattern' import type { RouteArgs } from './types/args.js' -import type { - RouteRequest, - RouteRequestFactory, -} from './types/request.js' +import type { RouteRequest, RouteRequestFactory } from './types/request.js' import type { RouteSchema } from './types/schema.js' /** HTTP methods supported by Rouzer action declarations. */ @@ -157,7 +154,9 @@ function action( schema?: RouteSchema ) { const path = - typeof pathOrSchema === 'string' ? RoutePattern.parse(pathOrSchema) : undefined + typeof pathOrSchema === 'string' + ? RoutePattern.parse(pathOrSchema) + : undefined schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema const request = ((args: RouteArgs = {}): RouteRequest => ({ schema, diff --git a/src/response-map.ts b/src/response-map.ts new file mode 100644 index 0000000..b715f06 --- /dev/null +++ b/src/response-map.ts @@ -0,0 +1,45 @@ +import { + getResponsePluginMarkerId, + responsePluginMarker, +} from './response.js' +import { $error } from './type.js' +import type { RouteResponseMap, RouteSchema } from './types/schema.js' + +/** Return true when the response schema is a status-keyed response map. */ +export function isResponseMap( + response: RouteSchema['response'] +): response is RouteResponseMap { + return ( + typeof response === 'object' && + response !== null && + !(responsePluginMarker in response) + ) +} + +/** Return true when the marker represents a declared error response. */ +export function isErrorMarker(marker: unknown): boolean { + return marker === $error.symbol +} + +/** Return true when the marker represents a success response. */ +export function isSuccessMarker(marker: unknown): boolean { + return marker !== undefined && !isErrorMarker(marker) +} + +/** Find the default success status for a direct handler result. */ +export function getResponseMapPluginIds(responseMap: RouteResponseMap): string[] { + return Object.values(responseMap).flatMap(marker => { + const pluginId = getResponsePluginMarkerId(marker) + return pluginId ? [pluginId] : [] + }) +} + +export function getDefaultSuccessStatus(responseMap: RouteResponseMap): number { + for (const key of Object.keys(responseMap)) { + const marker = responseMap[Number(key)] + if (isSuccessMarker(marker)) { + return Number(key) + } + } + return 200 +} diff --git a/src/response.ts b/src/response.ts index 3616d15..0ceb528 100644 --- a/src/response.ts +++ b/src/response.ts @@ -2,22 +2,22 @@ import type { Promisable } from './common.js' import type { RouteRequest } from './types/request.js' /** Runtime key carried by response plugin markers. */ -export const responsePluginMarkerSymbol: unique symbol = Symbol.for( - 'rouzer.response-plugin' -) as any +export const responsePluginMarker = Symbol.for('rouzer.response-plugin') /** * Compile-time response marker handled by a client/router response plugin pair. * * @remarks `TClient` is the value returned by generated client action * functions. `TRouter` is the non-`Response` value accepted from route handlers. + * Plugin markers may be used directly as an action response or as success + * entries in a status-keyed response map. */ export type ResponsePluginMarker< TClient, TRouter = TClient, TId extends string = string, -> = { - readonly [responsePluginMarkerSymbol]: { +> = Record & { + readonly [responsePluginMarker]: { readonly id: TId readonly client: TClient readonly router: TRouter @@ -59,7 +59,7 @@ export function createResponsePluginMarker< const TId extends string = string, >(id: TId): ResponsePluginMarker { return { - [responsePluginMarkerSymbol]: { + [responsePluginMarker]: { id, client: undefined!, router: undefined!, @@ -70,7 +70,7 @@ export function createResponsePluginMarker< /** Get the response plugin id from a plugin marker, if present. */ export function getResponsePluginMarkerId(value: unknown): string | undefined { return isResponsePluginMarker(value) - ? value[responsePluginMarkerSymbol].id + ? value[responsePluginMarker].id : undefined } @@ -79,9 +79,7 @@ export function isResponsePluginMarker( value: unknown ): value is ResponsePluginMarker { return ( - typeof value === 'object' && - value !== null && - responsePluginMarkerSymbol in value + typeof value === 'object' && value !== null && responsePluginMarker in value ) } diff --git a/src/server/router.ts b/src/server/router.ts index 2be54d9..e2bd856 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -18,7 +18,13 @@ import { type ResponsePluginMarker, type RouterResponsePlugin, } from '../response.js' -import type { RouteSchema } from '../types/schema.js' +import { + getDefaultSuccessStatus, + getResponseMapPluginIds, + isErrorMarker, + isResponseMap, +} from '../response-map.js' +import type { RouteResponseMap, RouteSchema } from '../types/schema.js' import type { RouteRequestHandlerMap } from '../types/server.js' export { chain } @@ -215,6 +221,21 @@ class RouterObject extends MiddlewareChain { } } + if (isResponseMap(schema.response)) { + ;(context as any).error = createResponseHelper( + schema.response, + request, + responsePlugins, + true + ) + ;(context as any).success = createResponseHelper( + schema.response, + request, + responsePlugins, + false + ) + } + const result = await handler(context) addDebugHeaders?.(context, route) if (result instanceof Response) { @@ -231,6 +252,16 @@ class RouterObject extends MiddlewareChain { request, }) } + if (isResponseMap(schema.response)) { + const status = getDefaultSuccessStatus(schema.response) + return encodeResponseMapResult( + schema.response, + status, + result, + request, + responsePlugins + ) + } return Response.json(result) } } as any) @@ -335,9 +366,15 @@ function validateRouterResponsePlugins( plugins: Map ) { for (const route of routes) { - const pluginId = getResponsePluginMarkerId(route.schema.response) - if (pluginId && !plugins.has(pluginId)) { - throw missingRouterResponsePlugin(pluginId) + const pluginIds = isResponseMap(route.schema.response) + ? getResponseMapPluginIds(route.schema.response) + : [getResponsePluginMarkerId(route.schema.response)].filter( + pluginId => pluginId !== undefined + ) + for (const pluginId of pluginIds) { + if (!plugins.has(pluginId)) { + throw missingRouterResponsePlugin(pluginId) + } } } } @@ -481,3 +518,60 @@ function createOriginPattern(origin: string) { } return new ExactPattern(origin) } + +/** Create `ctx.error(status, body)` or `ctx.success(status, body)`. */ +function createResponseHelper( + responseMap: RouteResponseMap, + request: Request, + responsePlugins: Map, + error: boolean +) { + return (status: number, body: unknown): Promise | Response => { + const marker = responseMap[status] + if (!marker || isErrorMarker(marker) !== error) { + throw new Error( + `Undeclared ${error ? 'error' : 'success'} response status: ${status}` + ) + } + return encodeResponseMapResult( + responseMap, + status, + body, + request, + responsePlugins + ) + } +} + +async function encodeResponseMapResult( + responseMap: RouteResponseMap, + status: number, + value: unknown, + request: Request, + responsePlugins: Map +): Promise { + const marker = responseMap[status] + if (!marker) { + throw new Error(`Undeclared response status: ${status}`) + } + if (isErrorMarker(marker)) { + return Response.json(value, { status }) + } + const pluginId = getResponsePluginMarkerId(marker) + if (!pluginId) { + return Response.json(value, { status }) + } + const plugin = responsePlugins.get(pluginId) + if (!plugin) { + throw missingRouterResponsePlugin(pluginId) + } + const response = await plugin.encode(value, { + marker: marker as ResponsePluginMarker, + request, + }) + return new Response(response.body, { + status, + statusText: response.statusText, + headers: response.headers, + }) +} diff --git a/src/type.ts b/src/type.ts index 3970550..fb367c9 100644 --- a/src/type.ts +++ b/src/type.ts @@ -1,11 +1,14 @@ -import type { Unchecked } from './common.js' +import type { Unchecked, UncheckedError } from './common.js' /** * Create a compile-time-only marker for an action's JSON response payload type. * - * @remarks `$type()` does not perform runtime validation. It lets Rouzer type - * server handler return values and client action functions for HTTP actions - * whose responses are expected to be JSON. + * @remarks `$type()` does not validate handler return values at the server + * boundary. It lets Rouzer type server handler return values and client action + * functions for HTTP actions whose responses are expected to be JSON. Use it + * directly as `response` for one JSON success shape, or as a success entry in a + * status-keyed response map. Validate response data where it enters your server + * or client code when runtime integrity is required. * * @example * ```ts @@ -22,3 +25,31 @@ export function $type() { } $type.symbol = Symbol() + +/** + * Create a compile-time-only marker for a declared error response type. + * + * @remarks `$error()` marks a non-success response branch in a status-keyed + * response map. It is a type contract, not a runtime validator. On the server, + * handlers use `ctx.error(status, body)` to return declared errors. On the + * client, declared error responses resolve as `[error, null, status]` tuple + * entries instead of rejecting the promise. + * + * @example + * ```ts + * import { $type, $error } from 'rouzer' + * import * as http from 'rouzer/http' + * + * const getUser = http.get('users/:id', { + * response: { + * 200: $type(), + * 404: $error<{ code: string; message: string }>(), + * }, + * }) + * ``` + */ +export function $error() { + return $error.symbol as unknown as UncheckedError +} + +$error.symbol = Symbol() diff --git a/src/types/args.ts b/src/types/args.ts index fcf8ec6..b5d3033 100644 --- a/src/types/args.ts +++ b/src/types/args.ts @@ -1,6 +1,10 @@ import type { MatchParams } from '@remix-run/route-pattern/match' import type * as z from 'zod' -import type { MutationRouteSchema, QueryRouteSchema, RouteSchema } from './schema.js' +import type { + MutationRouteSchema, + QueryRouteSchema, + RouteSchema, +} from './schema.js' declare class Any { private isAny: true diff --git a/src/types/handler.ts b/src/types/handler.ts index 7b54b26..37fb6f1 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -3,52 +3,135 @@ import type { AnyMiddlewareChain, MiddlewareContext } from 'alien-middleware' import type * as z from 'zod' import { Promisable } from '../common.js' import type { HttpAction } from '../http.js' -import type { InferRouteHandlerResult } from './response.js' -import type { RouteSchema } from './schema.js' +import type { + InferRouteHandlerResult, + InferResponseMapErrors, + InferResponseMapSuccesses, +} from './response.js' +import type { RouteResponseMap, RouteSchema } from './schema.js' type RequestContext = MiddlewareContext +/** + * Error response returned by `ctx.error(status, body)` in route handlers. + * + * @remarks This is an opaque branded type returned by the error helper. Route + * handlers may return it to signal a declared error response. + */ +export type RouteErrorResponse = Response & { __routeError__: true } + +/** Response returned by `ctx.success(status, body)` in route handlers. */ +export type RouteSuccessResponse = Response & { __routeSuccess__: true } + export type RouteRequestHandler< TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult, + TErrors = never, + TSuccesses = never, > = ( - context: RequestContext & TArgs + context: RequestContext & + TArgs & + ([TErrors] extends [never] + ? {} + : { + /** + * Return a declared error response. + * + * @remarks Only statuses declared with `$error()` in the response + * map are accepted. + */ + error: ( + ...args: TEntry extends [infer S extends number, infer B] + ? [status: S, body: B] + : never + ) => RouteErrorResponse + }) & + ([TSuccesses] extends [never] + ? {} + : { + /** + * Return a declared success response with an explicit status. + * + * @remarks Useful when a response map declares multiple 2xx statuses. + */ + success: ( + ...args: TEntry extends [infer S extends number, infer B] + ? [status: S, body: B] + : never + ) => RouteSuccessResponse + }) ) => Promisable export type InferActionHandler< TMiddleware extends AnyMiddlewareChain, TAction extends HttpAction, TPath extends string, -> = TAction['method'] extends 'GET' - ? RouteRequestHandler< - TMiddleware, - { - path: TAction['schema'] extends { path: any } - ? z.infer - : MatchParams - query: TAction['schema'] extends { query: any } - ? z.infer - : undefined - headers: TAction['schema'] extends { headers: any } - ? z.infer - : undefined - }, - InferRouteHandlerResult> - > - : RouteRequestHandler< - TMiddleware, - { - path: TAction['schema'] extends { path: any } - ? z.infer - : MatchParams - body: TAction['schema'] extends { body: any } - ? z.infer - : undefined - headers: TAction['schema'] extends { headers: any } - ? z.infer - : undefined - }, - InferRouteHandlerResult> - > +> = TAction['schema'] extends { response: infer R extends RouteResponseMap } + ? TAction['method'] extends 'GET' + ? RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + query: TAction['schema'] extends { query: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult>, + InferResponseMapErrors, + InferResponseMapSuccesses + > + : RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + body: TAction['schema'] extends { body: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult>, + InferResponseMapErrors, + InferResponseMapSuccesses + > + : TAction['method'] extends 'GET' + ? RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + query: TAction['schema'] extends { query: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult> + > + : RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + body: TAction['schema'] extends { body: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult> + > diff --git a/src/types/response.ts b/src/types/response.ts index 2ecdec9..bfdbb3a 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -1,24 +1,95 @@ -import type { ResponsePluginMarker, RouteSchema, Unchecked } from './schema.js' +import type { Unchecked, UncheckedError } from '../common.js' +import type { ResponsePluginMarker } from '../response.js' +import type { RouteResponseMap, RouteSchema } from './schema.js' /** `Response` whose `.json()` method resolves to a known payload type. */ export type RouteResponse = Response & { json(): Promise } -/** Infer the client response type from an action schema. */ +/** + * Helper: given a status-keyed response map, produce the discriminated tuple + * union for the client. + * + * Each entry becomes: + * - `$type()` → `[null, T, Status]` + * - `$error()` → `[T, null, Status]` + */ +type InferResponseMapClient = { + [K in keyof T & number]: T[K] extends UncheckedError + ? [TError, null, K] + : T[K] extends Unchecked + ? [null, TSuccess, K] + : T[K] extends ResponsePluginMarker + ? [null, TClient, K] + : never +}[keyof T & number] + +/** + * Infer the generated client action result type from an action schema. + * + * @remarks Direct JSON markers infer their payload type, plugin markers infer + * their client result type, and status-keyed response maps infer a tuple union + * of `[null, value, status]` success entries and `[error, null, status]` error + * entries. + */ export type InferRouteResponse = T extends { - response: ResponsePluginMarker + response: infer R } - ? TClient - : T extends { response: Unchecked } - ? TResponse - : void + ? R extends ResponsePluginMarker + ? TClient + : R extends Unchecked + ? TResponse + : R extends RouteResponseMap + ? InferResponseMapClient + : void + : void + +/** + * Helper: given a status-keyed response map, produce the union of handler + * result types (success values the handler can return directly). + */ +type InferResponseMapHandlerResult = { + [K in keyof T & number]: T[K] extends Unchecked + ? TSuccess + : T[K] extends ResponsePluginMarker + ? TRouter + : never +}[keyof T & number] -/** Infer the non-`Response` handler result type from an action schema. */ +/** + * Infer the non-`Response` handler result type from an action schema. + * + * @remarks For status-keyed response maps, this includes only success result + * values. Declared error responses are returned with `ctx.error(status, body)`. + */ export type InferRouteHandlerResult = T extends { - response: ResponsePluginMarker + response: infer R } - ? TRouter - : T extends { response: Unchecked } - ? TResponse - : void + ? R extends ResponsePluginMarker + ? TRouter + : R extends Unchecked + ? TResponse + : R extends RouteResponseMap + ? InferResponseMapHandlerResult + : void + : void + +/** + * Helper: given a status-keyed response map, extract error entries as a union + * of `[status, body]` pairs for typing `ctx.error(status, body)`. + */ +export type InferResponseMapErrors = { + [K in keyof T & number]: T[K] extends UncheckedError + ? [K, TError] + : never +}[keyof T & number] + +/** Extract success entries as a union of `[status, body]` pairs. */ +export type InferResponseMapSuccesses = { + [K in keyof T & number]: T[K] extends Unchecked + ? [K, TSuccess] + : T[K] extends ResponsePluginMarker + ? [K, TRouter] + : never +}[keyof T & number] diff --git a/src/types/schema.ts b/src/types/schema.ts index 75aee3d..1b9c4a3 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import type { Unchecked } from '../common.js' +import type { Unchecked, UncheckedError } from '../common.js' import type { ResponsePluginMarker } from '../response.js' /** @@ -9,13 +9,30 @@ import type { ResponsePluginMarker } from '../response.js' * @remarks Application code should usually call `$type()` instead of naming * this marker directly. */ -export type { Unchecked } -export type { ResponsePluginMarker } +export type { ResponsePluginMarker, Unchecked, UncheckedError } + +/** Single response marker accepted by status-keyed response maps. */ +export type RouteResponseMarker = + | Unchecked + | UncheckedError + | ResponsePluginMarker + +/** + * Status-keyed response map for declaring multiple response types. + * + * @remarks Numeric keys are HTTP status codes. Use `$type()` or a response + * plugin marker for success responses and `$error()` for declared error + * JSON responses. + */ +export type RouteResponseMap = { + [status: number]: RouteResponseMarker +} /** Response marker accepted by HTTP action schemas. */ export type RouteResponseSchema = | Unchecked | ResponsePluginMarker + | RouteResponseMap /** Schema shape for `GET` route methods. */ export type QueryRouteSchema = { diff --git a/test/error-responses.test-d.ts b/test/error-responses.test-d.ts new file mode 100644 index 0000000..c861fe0 --- /dev/null +++ b/test/error-responses.test-d.ts @@ -0,0 +1,92 @@ +import { $type, $error, createClient, createRouter } from 'rouzer' +import * as http from 'rouzer/http' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false + +type Assert = T + +// --- Route with response map --- + +type User = { id: string; name: string } +type NotFoundError = { code: 'NOT_FOUND'; message: string } +type AuthError = { code: 'UNAUTHORIZED'; message: string } + +const getUser = http.get('users/:id', { + response: { + 200: $type(), + 201: $type(), + 401: $error(), + 404: $error(), + }, +}) + +const routes = { getUser } + +const client = createClient({ + baseURL: 'https://example.com/api/', + routes, +}) + +// Client action returns a discriminated tuple +type GetUserResult = Awaited> + +type _ClientReturnsDiscriminatedTuple = Assert< + Equal< + GetUserResult, + | [null, User, 200] + | [null, User, 201] + | [AuthError, null, 401] + | [NotFoundError, null, 404] + > +> + +// Handler can return success value or ctx.error(...) +createRouter().use(routes, { + getUser(ctx) { + if (ctx.path.id === 'missing') { + return ctx.error(404, { code: 'NOT_FOUND', message: 'not found' }) + } + if (ctx.path.id === 'unauthorized') { + return ctx.error(401, { code: 'UNAUTHORIZED', message: 'no auth' }) + } + if (ctx.path.id === 'created') { + return ctx.success(201, { id: ctx.path.id, name: 'Ada' }) + } + return { id: ctx.path.id, name: 'Ada' } + }, +}) + +createRouter().use(routes, { + getUser(ctx) { + // @ts-expect-error 500 is not a declared error status. + ctx.error(500, { code: 'NOT_FOUND', message: 'nope' }) + // @ts-expect-error Error body must match the selected status. + ctx.error(404, { code: 'UNAUTHORIZED', message: 'nope' }) + // @ts-expect-error 404 is not a declared success status. + ctx.success(404, { code: 'NOT_FOUND', message: 'nope' }) + // @ts-expect-error Success body must match the selected status. + ctx.success(201, { id: 123, name: 'Ada' }) + return { id: ctx.path.id, name: 'Ada' } + }, +}) + +// --- Verify existing $type() still works --- + +const simpleRoute = http.get('simple', { + response: $type<{ message: string }>(), +}) + +const simpleRoutes = { simpleRoute } +const simpleClient = createClient({ + baseURL: 'https://example.com/api/', + routes: simpleRoutes, +}) + +type SimpleResult = Awaited> + +type _SimpleRouteStillReturnsPlainType = Assert< + Equal +> diff --git a/test/examples.test.ts b/test/examples.test.ts index 79fb61d..b62c41b 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -1,4 +1,5 @@ import { runBasicUsageExample } from '../examples/basic-usage.js' +import { runErrorResponsesExample } from '../examples/error-responses.js' import { runNdjsonStreamExample } from '../examples/ndjson-stream.js' test('basic usage example stays runnable', async () => { @@ -18,6 +19,19 @@ test('basic usage example stays runnable', async () => { }) }) +test('typed error response example stays runnable', async () => { + await expect(runErrorResponsesExample()).resolves.toEqual({ + found: [null, { id: '42', name: 'Ada' }, 200], + created: [null, { id: 'created', name: 'Grace' }, 201], + missing: [{ code: 'NOT_FOUND', message: 'User not found' }, null, 404], + unauthorized: [ + { code: 'UNAUTHORIZED', message: 'Login required' }, + null, + 401, + ], + }) +}) + test('NDJSON stream example stays runnable', async () => { await expect(runNdjsonStreamExample()).resolves.toEqual([ { id: 1, message: 'ready' }, diff --git a/test/fixtures/error-responses/handler.ts b/test/fixtures/error-responses/handler.ts new file mode 100644 index 0000000..841c8e2 --- /dev/null +++ b/test/fixtures/error-responses/handler.ts @@ -0,0 +1,47 @@ +import { createRouter } from 'rouzer' +import * as ndjson from 'rouzer/ndjson' +import * as routes from './routes.js' + +export default createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, { + getUser(ctx) { + const id = ctx.path.id + + if (id === 'unauthorized') { + return ctx.error(401, { + code: 'UNAUTHORIZED', + message: 'Login required', + }) + } + + if (id === 'created') { + return ctx.success(201, { + id, + name: 'Grace', + }) + } + + if (id === 'missing') { + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'User not found', + }) + } + + return { + id, + name: 'Ada', + } + }, + streamUsers(ctx) { + if (ctx.query === undefined) { + return [ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + ] + } + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'No stream', + }) + }, +}) diff --git a/test/fixtures/error-responses/routes.ts b/test/fixtures/error-responses/routes.ts new file mode 100644 index 0000000..a390057 --- /dev/null +++ b/test/fixtures/error-responses/routes.ts @@ -0,0 +1,34 @@ +import { $type, $error } from 'rouzer' +import * as http from 'rouzer/http' +import * as ndjson from 'rouzer/ndjson' + +export type User = { + id: string + name: string +} + +export type NotFoundError = { + code: 'NOT_FOUND' + message: string +} + +export type AuthError = { + code: 'UNAUTHORIZED' + message: string +} + +export const getUser = http.get('users/:id', { + response: { + 200: $type(), + 201: $type(), + 401: $error(), + 404: $error(), + }, +}) + +export const streamUsers = http.get('users.ndjson', { + response: { + 200: ndjson.$type(), + 404: $error(), + }, +}) diff --git a/test/fixtures/error-responses/test.ts b/test/fixtures/error-responses/test.ts new file mode 100644 index 0000000..ce8b696 --- /dev/null +++ b/test/fixtures/error-responses/test.ts @@ -0,0 +1,63 @@ +import * as ndjson from 'rouzer/ndjson' +import { createTest } from '../shared.js' +import handler from './handler.js' +import * as routes from './routes.js' + +export default createTest({ + name: 'typed error responses ($error with status-keyed response map)', + routes, + handler, + clientPlugins: [ndjson.clientPlugin], + test: async client => { + // Success case: returns tuple [null, User, 200] + const [error1, result1, status1] = await client.getUser({ + path: { id: '42' }, + }) + expect(error1).toBeNull() + expect(result1).toEqual({ id: '42', name: 'Ada' }) + expect(status1).toBe(200) + + // Explicit success case: returns tuple [null, User, 201] + const [error4, result4, status4] = await client.getUser({ + path: { id: 'created' }, + }) + expect(error4).toBeNull() + expect(result4).toEqual({ id: 'created', name: 'Grace' }) + expect(status4).toBe(201) + + // Plugin success case inside a response map. + const [streamError, stream, streamStatus] = await client.streamUsers() + expect(streamError).toBeNull() + expect(streamStatus).toBe(200) + const users = [] + for await (const user of stream!) { + users.push(user) + } + expect(users).toEqual([ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + ]) + + // 401 error case: returns tuple [AuthError, null, 401] + const [error2, result2, status2] = await client.getUser({ + path: { id: 'unauthorized' }, + }) + expect(error2).toEqual({ + code: 'UNAUTHORIZED', + message: 'Login required', + }) + expect(result2).toBeNull() + expect(status2).toBe(401) + + // 404 error case: returns tuple [NotFoundError, null, 404] + const [error3, result3, status3] = await client.getUser({ + path: { id: 'missing' }, + }) + expect(error3).toEqual({ + code: 'NOT_FOUND', + message: 'User not found', + }) + expect(result3).toBeNull() + expect(status3).toBe(404) + }, +})