Skip to content

Commit 8366106

Browse files
committed
feat(server): Change the nest integration to support Nest features that were competing with Orpc
1 parent 158dbb5 commit 8366106

File tree

9 files changed

+861
-33
lines changed

9 files changed

+861
-33
lines changed

packages/nest/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,34 @@ You can find the full documentation [here](https://orpc.unnoq.com).
6868

6969
Deeply integrate oRPC with [NestJS](https://nestjs.com/). Read the [documentation](https://orpc.unnoq.com/docs/openapi/integrations/implement-contract-in-nest) for more information.
7070

71+
### Error Handling
72+
73+
oRPC provides an optional `ORPCExceptionFilter` that catches `ORPCError` instances and converts them to standardized oRPC error responses. You can choose to:
74+
75+
1. **Use the built-in filter** for standard oRPC error responses
76+
2. **Handle ORPCError in your own custom exception filters** for custom error formatting
77+
3. **Let NestJS default error handling take over**
78+
79+
```ts
80+
import { ORPCExceptionFilter } from '@orpc/nest'
81+
82+
// Option 1: Register globally in your app
83+
app.useGlobalFilters(new ORPCExceptionFilter())
84+
85+
// Option 2: Register as a provider in your module
86+
@Module({
87+
providers: [
88+
{
89+
provide: APP_FILTER,
90+
useClass: ORPCExceptionFilter,
91+
},
92+
],
93+
})
94+
export class AppModule {}
95+
```
96+
97+
**Note:** All errors thrown in oRPC handlers (including decoding/encoding errors) are now allowed to bubble up to NestJS exception filters. This gives you full control over error handling while maintaining compatibility with NestJS's exception filter system.
98+
7199
### Implement Contract
72100

73101
An overview of how to implement an [oRPC contract](https://orpc.unnoq.com/docs/contract-first/define-contract) in NestJS.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'
2+
import type { Response } from 'express'
3+
import type { FastifyReply } from 'fastify'
4+
import { Catch, Injectable } from '@nestjs/common'
5+
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
6+
import { StandardOpenAPICodec } from '@orpc/openapi/standard'
7+
import { ORPCError } from '@orpc/server'
8+
import * as StandardServerFastify from '@orpc/standard-server-fastify'
9+
import * as StandardServerNode from '@orpc/standard-server-node'
10+
11+
const codec = new StandardOpenAPICodec(
12+
new StandardOpenAPISerializer(
13+
new StandardOpenAPIJsonSerializer(),
14+
new StandardBracketNotationSerializer(),
15+
),
16+
)
17+
18+
/**
19+
* Global exception filter that catches ORPCError instances and converts them
20+
* to standardized oRPC error responses.
21+
*
22+
* This filter is optional - you can choose to:
23+
* 1. Use this filter to get standard oRPC error responses
24+
* 2. Handle ORPCError in your own custom exception filters
25+
* 3. Let NestJS default error handling take over
26+
*
27+
* @example
28+
* ```typescript
29+
* // Register globally in your app
30+
* app.useGlobalFilters(new ORPCExceptionFilter())
31+
*
32+
* // Or register as a provider
33+
* @Module({
34+
* providers: [
35+
* {
36+
* provide: APP_FILTER,
37+
* useClass: ORPCExceptionFilter,
38+
* },
39+
* ],
40+
* })
41+
* export class AppModule {}
42+
* ```
43+
*/
44+
@Catch(ORPCError)
45+
@Injectable()
46+
export class ORPCExceptionFilter implements ExceptionFilter {
47+
constructor(
48+
private config: StandardServerNode.SendStandardResponseOptions | undefined,
49+
) {}
50+
51+
async catch(exception: ORPCError<any, any>, host: ArgumentsHost) {
52+
const ctx = host.switchToHttp()
53+
const res = ctx.getResponse<Response | FastifyReply>()
54+
const standardResponse = codec.encodeError(exception)
55+
// Send the response directly with proper status and headers
56+
const isFastify = 'raw' in res
57+
if (isFastify) {
58+
await StandardServerFastify.sendStandardResponse(res as FastifyReply, standardResponse, this.config)
59+
}
60+
else {
61+
await StandardServerNode.sendStandardResponse(res as Response, standardResponse, this.config)
62+
}
63+
}
64+
}

packages/nest/src/implement.test.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@ import type { Request } from 'express'
33
import type { FastifyReply } from 'fastify'
44
import FastifyCookie from '@fastify/cookie'
55
import { Controller, Req, Res } from '@nestjs/common'
6-
import { REQUEST } from '@nestjs/core'
6+
import { APP_FILTER, REQUEST } from '@nestjs/core'
77
import { FastifyAdapter } from '@nestjs/platform-fastify'
88
import { Test } from '@nestjs/testing'
99
import { oc, ORPCError } from '@orpc/contract'
1010
import { implement, lazy } from '@orpc/server'
1111
import * as StandardServerNode from '@orpc/standard-server-node'
1212
import supertest from 'supertest'
1313
import { expect, it, vi } from 'vitest'
14-
import * as z from 'zod'
14+
import { z } from 'zod'
15+
import { ORPCExceptionFilter } from './filters/orpc-exception.filter'
1516
import { Implement } from './implement'
1617
import { ORPCModule } from './module'
1718

1819
const sendStandardResponseSpy = vi.spyOn(StandardServerNode, 'sendStandardResponse')
20+
const setStandardResponseSpy = vi.spyOn(StandardServerNode, 'setStandardResponse')
1921

2022
beforeEach(() => {
2123
vi.clearAllMocks()
@@ -134,6 +136,14 @@ describe('@Implement', async () => {
134136
] as const)('type: $1', async (Controller, _) => {
135137
const moduleRef = await Test.createTestingModule({
136138
controllers: [Controller],
139+
// The pong test throw errors and need this filter to match the
140+
// expected behaviour.
141+
providers: [
142+
{
143+
provide: APP_FILTER,
144+
useClass: ORPCExceptionFilter,
145+
},
146+
],
137147
}).compile()
138148

139149
const app = moduleRef.createNestApplication()
@@ -186,6 +196,7 @@ describe('@Implement', async () => {
186196
name: 'world',
187197
},
188198
}))
199+
expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1)
189200

190201
expect(req).toBeDefined()
191202
expect(req!.method).toEqual('GET')
@@ -369,6 +380,7 @@ describe('@Implement', async () => {
369380
}).compile()
370381

371382
const app = moduleRef.createNestApplication()
383+
app.useGlobalFilters(new ORPCExceptionFilter())
372384
await app.init()
373385

374386
const httpServer = app.getHttpServer()
@@ -411,8 +423,8 @@ describe('@Implement', async () => {
411423
expect(res.body).toEqual('pong')
412424

413425
expect(interceptor).toHaveBeenCalledTimes(1)
414-
expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1)
415-
expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
426+
expect(setStandardResponseSpy).toHaveBeenCalledTimes(1)
427+
expect(setStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
416428
eventIteratorKeepAliveComment: '__TEST__',
417429
}))
418430
})
@@ -459,8 +471,8 @@ describe('@Implement', async () => {
459471
}),
460472
}),
461473
}))
462-
expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1)
463-
expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
474+
expect(setStandardResponseSpy).toHaveBeenCalledTimes(1)
475+
expect(setStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
464476
eventIteratorKeepAliveComment: '__TEST__',
465477
}))
466478
})

packages/nest/src/implement.ts

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify'
99
import type { Observable } from 'rxjs'
1010
import type { ORPCModuleConfig } from './module'
1111
import { applyDecorators, Delete, Get, Head, Inject, Injectable, Optional, Patch, Post, Put, UseInterceptors } from '@nestjs/common'
12-
import { toORPCError } from '@orpc/client'
1312
import { fallbackContractConfig, isContractProcedure } from '@orpc/contract'
1413
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
1514
import { StandardOpenAPICodec } from '@orpc/openapi/standard'
@@ -124,40 +123,63 @@ export class ImplementInterceptor implements NestInterceptor {
124123
? StandardServerFastify.toStandardLazyRequest(req, res as FastifyReply)
125124
: StandardServerNode.toStandardLazyRequest(req, res as Response)
126125

127-
const standardResponse: StandardResponse = await (async () => {
128-
let isDecoding = false
126+
// Pass the original NestJS request as context
127+
const contextWithRequest = {
128+
...(this.config?.context || {}),
129+
request: req,
130+
response: res,
131+
}
129132

133+
const client = createProcedureClient(procedure, {
134+
...this.config,
135+
context: contextWithRequest,
136+
})
137+
const standardResponse: StandardResponse = await (async (): Promise<StandardResponse> => {
138+
// Decode input - catch only non-ORPC decoding errors and convert to ORPCError
139+
let input: Awaited<ReturnType<typeof codec.decode>>
130140
try {
131-
const client = createProcedureClient(procedure, this.config)
132-
133-
isDecoding = true
134-
const input = await codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure)
135-
isDecoding = false
141+
input = await codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure)
142+
}
143+
catch (e: any) {
144+
let error: ORPCError<any, any> = e
145+
// Malformed request - wrap in ORPCError and let exception filters handle it
146+
if (!(e instanceof ORPCError)) {
147+
error = new ORPCError('BAD_REQUEST', {
148+
message: `Malformed request. Ensure the request body is properly formatted and the 'Content-Type' header is set correctly.`,
149+
cause: e,
150+
})
151+
}
152+
return codec.encodeError(error)
153+
}
136154

137-
const output = await client(input, {
138-
signal: standardRequest.signal,
139-
lastEventId: flattenHeader(standardRequest.headers['last-event-id']),
140-
})
155+
// Execute handler - let all errors bubble up to NestJS exception filters
156+
const output = await client(input, {
157+
signal: standardRequest.signal,
158+
lastEventId: flattenHeader(standardRequest.headers['last-event-id']),
159+
})
141160

161+
// Encode output - catch only non-ORPC encoding errors and convert to ORPCError
162+
try {
142163
return codec.encode(output, procedure)
143164
}
144-
catch (e) {
145-
const error = isDecoding && !(e instanceof ORPCError)
146-
? new ORPCError('BAD_REQUEST', {
147-
message: `Malformed request. Ensure the request body is properly formatted and the 'Content-Type' header is set correctly.`,
148-
cause: e,
149-
})
150-
: toORPCError(e)
151-
165+
catch (e: any) {
166+
let error: ORPCError<any, any> = e
167+
// Encoding error means our handler returned invalid data
168+
if (!(e instanceof ORPCError)) {
169+
error = new ORPCError('INTERNAL_SERVER_ERROR', {
170+
message: `Failed to encode response. The handler may have returned data that doesn't match the contract output schema.`,
171+
cause: e,
172+
})
173+
}
152174
return codec.encodeError(error)
153175
}
154176
})()
155-
177+
// Set status and headers
156178
if ('raw' in res) {
157-
await StandardServerFastify.sendStandardResponse(res, standardResponse, this.config)
179+
return StandardServerFastify.setStandardResponse(res as FastifyReply, standardResponse, this.config)
158180
}
159181
else {
160-
await StandardServerNode.sendStandardResponse(res, standardResponse, this.config)
182+
return StandardServerNode.setStandardResponse(res as Response, standardResponse, this.config)
161183
}
162184
}),
163185
)

packages/nest/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './filters/orpc-exception.filter'
12
export * from './implement'
23
export { Implement as Impl } from './implement'
34
export * from './module'

0 commit comments

Comments
 (0)