Skip to content

Commit 30c0e6b

Browse files
authored
feat(client, contract, openapi): support 3xx response (#304)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Documentation** - Introduced a new “Redirect Response” page in the OpenAPI docs with an updated sidebar for easier navigation. - **New Features** - Enhanced HTTP redirect handling to offer more precise control over redirection. - Improved error response validations for clearer, more reliable feedback. - **Bug Fixes** - Updated error handling logic to provide more accurate status code validations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent bec9f4e commit 30c0e6b

File tree

14 files changed

+156
-72
lines changed

14 files changed

+156
-72
lines changed

apps/content/.vitepress/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export default defineConfig({
191191
text: 'Advanced',
192192
collapsed: true,
193193
items: [
194+
{ text: 'Redirect Response', link: '/docs/openapi/advanced/redirect-response' },
194195
{ text: 'OpenAPI JSON Serializer', link: '/docs/openapi/advanced/openapi-json-serializer' },
195196
],
196197
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
title: Redirect Response
3+
description: Standard HTTP redirect response in oRPC OpenAPI.
4+
---
5+
6+
# Redirect Response
7+
8+
Easily return a standard HTTP redirect response in oRPC OpenAPI.
9+
10+
## Basic Usage
11+
12+
By combining the `successStatus` and `outputStructure` options, you can return a standard HTTP redirect response.
13+
14+
```ts
15+
const redirect = os
16+
.route({
17+
method: 'GET',
18+
path: '/redirect',
19+
successStatus: 307, // [!code highlight]
20+
outputStructure: 'detailed' // [!code highlight]
21+
})
22+
.handler(async () => {
23+
return {
24+
headers: {
25+
location: 'https://orpc.unnoq.com', // [!code highlight]
26+
},
27+
}
28+
})
29+
```
30+
31+
## Limitations
32+
33+
When invoking a redirect procedure with [OpenAPILink](/docs/openapi/client/openapi-link), oRPC treats the redirect as a normal response rather than following it. Some environments, such as browsers, may restrict access to the redirect response, **potentially causing errors**. In contrast, server environments like Node.js handle this without issue.
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export * from './link-fetch-client'
22
export * from './rpc-link'
3-
export * from './types'

packages/client/src/adapters/fetch/link-fetch-client.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('linkFetchClient', () => {
4040
expect(fetch).toBeCalledTimes(1)
4141
expect(fetch).toBeCalledWith(
4242
toFetchRequestSpy.mock.results[0]!.value,
43-
{},
43+
{ redirect: 'manual' },
4444
options,
4545
['example'],
4646
{ body: true },

packages/client/src/adapters/fetch/link-fetch-client.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { toFetchRequest, toStandardLazyResponse } from '@orpc/standard-server-fe
77
export interface LinkFetchClientOptions<T extends ClientContext> extends ToFetchRequestOptions {
88
fetch?: (
99
request: Request,
10-
init: Record<never, never>,
10+
init: { redirect?: Request['redirect'] },
1111
options: ClientOptions<T>,
1212
path: readonly string[],
1313
input: unknown
@@ -26,7 +26,7 @@ export class LinkFetchClient<T extends ClientContext> implements StandardLinkCli
2626
async call(request: StandardRequest, options: ClientOptions<T>, path: readonly string[], input: unknown): Promise<StandardLazyResponse> {
2727
const fetchRequest = toFetchRequest(request, this.toFetchRequestOptions)
2828

29-
const fetchResponse = await this.fetch(fetchRequest, {}, options, path, input)
29+
const fetchResponse = await this.fetch(fetchRequest, { redirect: 'manual' }, options, path, input)
3030

3131
const lazyResponse = toStandardLazyResponse(fetchResponse)
3232

packages/client/src/adapters/fetch/types.ts

-11
This file was deleted.

packages/client/src/adapters/standard/rpc-link-codec.test.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as StandardServer from '@orpc/standard-server'
2-
import { ORPCError } from '../../error'
2+
import * as ErrorModule from '../../error'
33
import { StandardRPCJsonSerializer } from './rpc-json-serializer'
44
import { StandardRPCLinkCodec } from './rpc-link-codec'
55
import { StandardRPCSerializer } from './rpc-serializer'
66

7+
const ORPCError = ErrorModule.ORPCError
8+
const isORPCErrorStatusSpy = vi.spyOn(ErrorModule, 'isORPCErrorStatus')
79
const mergeStandardHeadersSpy = vi.spyOn(StandardServer, 'mergeStandardHeaders')
810

911
beforeEach(() => {
@@ -121,6 +123,9 @@ describe('standardRPCLinkCodec', () => {
121123

122124
expect(deserializeSpy).toBeCalledTimes(1)
123125
expect(deserializeSpy).toBeCalledWith(serialized)
126+
127+
expect(isORPCErrorStatusSpy).toBeCalledTimes(1)
128+
expect(isORPCErrorStatusSpy).toBeCalledWith(200)
124129
})
125130

126131
it('should decode error', async () => {
@@ -144,6 +149,9 @@ describe('standardRPCLinkCodec', () => {
144149

145150
expect(deserializeSpy).toBeCalledTimes(1)
146151
expect(deserializeSpy).toBeCalledWith(serialized)
152+
153+
expect(isORPCErrorStatusSpy).toBeCalledTimes(1)
154+
expect(isORPCErrorStatusSpy).toBeCalledWith(499)
147155
})
148156

149157
it('error: Cannot parse response body', async () => {

packages/client/src/adapters/standard/rpc-link-codec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { StandardRPCSerializer } from './rpc-serializer'
33
import type { StandardLinkCodec } from './types'
44
import { isAsyncIteratorObject, stringifyJSON, value, type Value } from '@orpc/shared'
55
import { mergeStandardHeaders, type StandardHeaders, type StandardLazyResponse, type StandardRequest } from '@orpc/standard-server'
6-
import { ORPCError } from '../../error'
6+
import { isORPCErrorStatus, ORPCError } from '../../error'
77
import { toHttpPath } from './utils'
88

99
export interface StandardRPCLinkCodecOptions<T extends ClientContext> {
@@ -117,7 +117,7 @@ export class StandardRPCLinkCodec<T extends ClientContext> implements StandardLi
117117
}
118118

119119
async decode(response: StandardLazyResponse): Promise<unknown> {
120-
const isOk = response.status >= 200 && response.status < 300
120+
const isOk = !isORPCErrorStatus(response.status)
121121

122122
const deserialized = await (async () => {
123123
let isBodyOk = false

packages/client/src/error.test.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fallbackORPCErrorMessage, fallbackORPCErrorStatus, isDefinedError, ORPCError, toORPCError } from './error'
1+
import { fallbackORPCErrorMessage, fallbackORPCErrorStatus, isDefinedError, isORPCErrorStatus, ORPCError, toORPCError } from './error'
22

33
it('fallbackORPCErrorStatus', () => {
44
expect(fallbackORPCErrorStatus('BAD_GATEWAY', 500)).toBe(500)
@@ -42,9 +42,9 @@ describe('oRPCError', () => {
4242

4343
it('oRPCError throw when invalid status', () => {
4444
expect(() => new ORPCError('BAD_GATEWAY', { status: 200 })).toThrowError()
45-
expect(() => new ORPCError('BAD_GATEWAY', { status: 299 })).toThrowError()
45+
expect(() => new ORPCError('BAD_GATEWAY', { status: 399 })).toThrowError()
4646

47-
expect(() => new ORPCError('BAD_GATEWAY', { status: 300 })).not.toThrowError()
47+
expect(() => new ORPCError('BAD_GATEWAY', { status: 400 })).not.toThrowError()
4848
expect(() => new ORPCError('BAD_GATEWAY', { status: 199 })).not.toThrowError()
4949
})
5050

@@ -107,3 +107,12 @@ it('toORPCError', () => {
107107
return true
108108
})
109109
})
110+
111+
it('isORPCErrorStatus', () => {
112+
expect(isORPCErrorStatus(200)).toBe(false)
113+
expect(isORPCErrorStatus(399)).toBe(false)
114+
115+
expect(isORPCErrorStatus(400)).toBe(true)
116+
expect(isORPCErrorStatus(499)).toBe(true)
117+
expect(isORPCErrorStatus(199)).toBe(true)
118+
})

packages/client/src/error.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ export class ORPCError<TCode extends ORPCErrorCode, TData> extends Error {
104104
readonly data: TData
105105

106106
constructor(code: TCode, ...[options]: MaybeOptionalOptions<ORPCErrorOptions<TData>>) {
107-
if (options?.status && (options.status >= 200 && options.status < 300)) {
108-
throw new Error('[ORPCError] The error status code must not be in the 200-299 range.')
107+
if (options?.status && !isORPCErrorStatus(options.status)) {
108+
throw new Error('[ORPCError] Invalid error status code.')
109109
}
110110

111111
const message = fallbackORPCErrorMessage(code, options?.message)
@@ -173,3 +173,7 @@ export function toORPCError(error: unknown): ORPCError<any, any> {
173173
cause: error,
174174
})
175175
}
176+
177+
export function isORPCErrorStatus(status: number): boolean {
178+
return status < 200 || status >= 400
179+
}

packages/contract/src/procedure.test.ts

+37-9
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,62 @@
1+
import * as ClientModule from '@orpc/client'
12
import { ping, pong } from '../tests/shared'
23
import { ContractProcedure, isContractProcedure } from './procedure'
34

5+
const isORPCErrorStatusSpy = vi.spyOn(ClientModule, 'isORPCErrorStatus')
6+
7+
beforeEach(() => {
8+
vi.clearAllMocks()
9+
})
10+
411
describe('contractProcedure', () => {
5-
it('throws error when route.successStatus is not between 200 and 299', () => {
6-
expect(
7-
() => new ContractProcedure({ ...ping['~orpc'], route: { successStatus: 100 } }),
8-
).toThrowError()
12+
it('throws error when route.successStatus is invalid', () => {
13+
isORPCErrorStatusSpy.mockReturnValueOnce(true)
14+
915
expect(
10-
() => new ContractProcedure({ ...ping['~orpc'], route: { successStatus: 300 } }),
16+
() => new ContractProcedure({ ...ping['~orpc'], route: { successStatus: 1999 } }),
1117
).toThrowError()
18+
19+
expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1)
20+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(1999)
21+
22+
isORPCErrorStatusSpy.mockClear()
23+
isORPCErrorStatusSpy.mockReturnValueOnce(false)
24+
1225
expect(
13-
() => new ContractProcedure({ ...ping['~orpc'], route: { successStatus: 299 } }),
26+
() => new ContractProcedure({ ...ping['~orpc'], route: { successStatus: 2000 } }),
1427
).not.toThrowError()
28+
29+
expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1)
30+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(2000)
1531
})
1632

1733
it('throws error when errorMap has invalid status code', () => {
34+
isORPCErrorStatusSpy.mockReturnValueOnce(false)
35+
1836
expect(
1937
() => new ContractProcedure({
2038
...ping['~orpc'],
21-
errorMap: { BAD_GATEWAY: { status: 100 } },
39+
errorMap: { BAD_GATEWAY: { status: 200 } },
2240
}),
2341
).toThrowError()
42+
43+
expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1)
44+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(200)
45+
46+
isORPCErrorStatusSpy.mockClear()
47+
isORPCErrorStatusSpy.mockReturnValueOnce(true)
48+
2449
expect(
2550
() => new ContractProcedure({
2651
...ping['~orpc'],
2752
errorMap: {
28-
BAD_GATEWAY: { status: 600 },
53+
BAD_GATEWAY: { status: 500 },
2954
},
3055
}),
31-
).toThrowError()
56+
).not.toThrowError()
57+
58+
expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1)
59+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(500)
3260
})
3361
})
3462

packages/contract/src/procedure.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ErrorMap } from './error'
22
import type { Meta } from './meta'
33
import type { Route } from './route'
44
import type { AnySchema } from './schema'
5+
import { isORPCErrorStatus } from '@orpc/client'
56

67
export interface ContractProcedureDef<
78
TInputSchema extends AnySchema,
@@ -25,12 +26,12 @@ export class ContractProcedure<
2526
'~orpc': ContractProcedureDef<TInputSchema, TOutputSchema, TErrorMap, TMeta>
2627

2728
constructor(def: ContractProcedureDef<TInputSchema, TOutputSchema, TErrorMap, TMeta>) {
28-
if (def.route?.successStatus && (def.route.successStatus < 200 || def.route?.successStatus > 299)) {
29-
throw new Error('[ContractProcedure] The successStatus must be between 200 and 299')
29+
if (def.route?.successStatus && isORPCErrorStatus(def.route.successStatus)) {
30+
throw new Error('[ContractProcedure] Invalid successStatus.')
3031
}
3132

32-
if (Object.values(def.errorMap).some(val => val && val.status && (val.status < 400 || val.status > 599))) {
33-
throw new Error('[ContractProcedure] The error status code must be in the 400-599 range.')
33+
if (Object.values(def.errorMap).some(val => val && val.status && !isORPCErrorStatus(val.status))) {
34+
throw new Error('[ContractProcedure] Invalid error status code.')
3435
}
3536

3637
this['~orpc'] = def

packages/openapi-client/src/adapters/standard/openapi-link-codec.test.ts

+46-34
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { ORPCError } from '@orpc/client'
1+
import * as ClientModule from '@orpc/client'
22
import * as StandardServer from '@orpc/standard-server'
33
import { oc } from '../../../../contract/src/builder'
44
import { StandardBracketNotationSerializer } from './bracket-notation'
55
import { StandardOpenAPIJsonSerializer } from './openapi-json-serializer'
66
import { StandardOpenapiLinkCodec } from './openapi-link-codec'
77
import { StandardOpenAPISerializer } from './openapi-serializer'
88

9+
const ORPCError = ClientModule.ORPCError
10+
const isORPCErrorStatusSpy = vi.spyOn(ClientModule, 'isORPCErrorStatus')
911
const mergeStandardHeadersSpy = vi.spyOn(StandardServer, 'mergeStandardHeaders')
1012

1113
beforeEach(() => {
@@ -282,6 +284,9 @@ describe('standardOpenapiLinkCodecOptions', () => {
282284

283285
expect(deserialize).toHaveBeenCalledTimes(1)
284286
expect(deserialize).toHaveBeenCalledWith(form)
287+
288+
expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1)
289+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(201)
285290
})
286291

287292
it('outputStructure=detailed', async () => {
@@ -300,6 +305,46 @@ describe('standardOpenapiLinkCodecOptions', () => {
300305

301306
expect(deserialize).toHaveBeenCalledTimes(1)
302307
expect(deserialize).toHaveBeenCalledWith(form)
308+
309+
expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1)
310+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(201)
311+
})
312+
313+
it('deserialize error', async () => {
314+
const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, {
315+
url: 'http://localhost:3000',
316+
})
317+
318+
await expect(codec.decode({
319+
headers: { 'x-custom': 'value' },
320+
body: async () => new ORPCError('BAD_GATEWAY', { status: 501, message: 'message', data: 'data' }).toJSON(),
321+
status: 501,
322+
}, { context: {}, signal }, ['ping'])).rejects.toSatisfy((error: any) => {
323+
expect(error).toBeInstanceOf(ORPCError)
324+
expect(error.code).toEqual('BAD_GATEWAY')
325+
expect(error.status).toBe(501)
326+
expect(error.message).toBe('message')
327+
expect(error.data).toBe('data')
328+
329+
return true
330+
})
331+
332+
await expect(codec.decode({
333+
headers: { 'x-custom': 'value' },
334+
body: async () => ({ something: 'data' }),
335+
status: 409,
336+
}, { context: {}, signal }, ['ping'])).rejects.toSatisfy((error: any) => {
337+
expect(error).toBeInstanceOf(ORPCError)
338+
expect(error.code).toEqual('MALFORMED_ORPC_ERROR_RESPONSE')
339+
expect(error.status).toBe(409)
340+
expect(error.data).toEqual({ something: 'data' })
341+
342+
return true
343+
})
344+
345+
expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(2)
346+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(501)
347+
expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(409)
303348
})
304349

305350
it('throw if not found a procedure', async () => {
@@ -341,38 +386,5 @@ describe('standardOpenapiLinkCodecOptions', () => {
341386
status: 201,
342387
}, { context: {}, signal }, ['ping'])).rejects.toThrow('Invalid OpenAPI response format.')
343388
})
344-
345-
it('deserialize error', async () => {
346-
const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, {
347-
url: 'http://localhost:3000',
348-
})
349-
350-
await expect(codec.decode({
351-
headers: { 'x-custom': 'value' },
352-
body: async () => new ORPCError('BAD_GATEWAY', { status: 501, message: 'message', data: 'data' }).toJSON(),
353-
status: 501,
354-
}, { context: {}, signal }, ['ping'])).rejects.toSatisfy((error: any) => {
355-
expect(error).toBeInstanceOf(ORPCError)
356-
expect(error.code).toEqual('BAD_GATEWAY')
357-
expect(error.status).toBe(501)
358-
expect(error.message).toBe('message')
359-
expect(error.data).toBe('data')
360-
361-
return true
362-
})
363-
364-
await expect(codec.decode({
365-
headers: { 'x-custom': 'value' },
366-
body: async () => ({ something: 'data' }),
367-
status: 409,
368-
}, { context: {}, signal }, ['ping'])).rejects.toSatisfy((error: any) => {
369-
expect(error).toBeInstanceOf(ORPCError)
370-
expect(error.code).toEqual('MALFORMED_ORPC_ERROR_RESPONSE')
371-
expect(error.status).toBe(409)
372-
expect(error.data).toEqual({ something: 'data' })
373-
374-
return true
375-
})
376-
})
377389
})
378390
})

0 commit comments

Comments
 (0)