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

14 files changed

Lines changed: 156 additions & 72 deletions

File tree

apps/content/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
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
},
Lines changed: 33 additions & 0 deletions
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.
Lines changed: 0 additions & 1 deletion
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 0 additions & 11 deletions
This file was deleted.

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

Lines changed: 9 additions & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 12 additions & 3 deletions
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

Lines changed: 6 additions & 2 deletions
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+
}

0 commit comments

Comments
 (0)