Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/nest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,34 @@ You can find the full documentation [here](https://orpc.unnoq.com).

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.

### Error Handling

oRPC provides an optional `ORPCExceptionFilter` that catches `ORPCError` instances and converts them to standardized oRPC error responses. You can choose to:

1. **Use the built-in filter** for standard oRPC error responses
2. **Handle ORPCError in your own custom exception filters** for custom error formatting
3. **Let NestJS default error handling take over**

```ts
import { ORPCExceptionFilter } from '@orpc/nest'

// Option 1: Register globally in your app
app.useGlobalFilters(new ORPCExceptionFilter())

// Option 2: Register as a provider in your module
@Module({
providers: [
{
provide: APP_FILTER,
useClass: ORPCExceptionFilter,
},
],
})
export class AppModule {}
```

**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.

### Implement Contract

An overview of how to implement an [oRPC contract](https://orpc.unnoq.com/docs/contract-first/define-contract) in NestJS.
Expand Down
7 changes: 6 additions & 1 deletion packages/nest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,23 @@
"@orpc/standard-server-node": "workspace:*"
},
"devDependencies": {
"@fastify/compress": "^8.1.0",
"@fastify/cookie": "^11.0.2",
"@nestjs/common": "^11.1.8",
"@nestjs/core": "^11.1.8",
"@nestjs/platform-express": "^11.1.8",
"@nestjs/platform-fastify": "^11.1.8",
"@nestjs/testing": "^11.1.8",
"@ts-rest/core": "^3.52.1",
"@types/compression": "^1.8.1",
"@types/express": "^5.0.5",
"@types/node": "^22.15.30",
"@types/supertest": "^6.0.3",
"compression": "^1.8.1",
"express": "^5.0.0",
"fastify": "^5.6.1",
"rxjs": "^7.8.1",
"supertest": "^7.1.4",
"zod": "^4.1.12"
}
}
}

Check failure on line 83 in packages/nest/package.json

View workflow job for this annotation

GitHub Actions / lint

Newline required at end of file but not found
64 changes: 64 additions & 0 deletions packages/nest/src/filters/orpc-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'
import type { Response } from 'express'
import type { FastifyReply } from 'fastify'
import { Catch, Injectable } from '@nestjs/common'
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
import { StandardOpenAPICodec } from '@orpc/openapi/standard'
import { ORPCError } from '@orpc/server'
import * as StandardServerFastify from '@orpc/standard-server-fastify'
import * as StandardServerNode from '@orpc/standard-server-node'

const codec = new StandardOpenAPICodec(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking for a way to make all codecs extendable. Specifically, I'd like to know if it's possible to add a custom serializer by passing it into the main configuration, similar to the method described for extending native data types.

new StandardOpenAPISerializer(
new StandardOpenAPIJsonSerializer(),
new StandardBracketNotationSerializer(),
),
)

/**
* Global exception filter that catches ORPCError instances and converts them
* to standardized oRPC error responses.
*
* This filter is optional - you can choose to:
* 1. Use this filter to get standard oRPC error responses
* 2. Handle ORPCError in your own custom exception filters
* 3. Let NestJS default error handling take over
*
* @example
* ```typescript
* // Register globally in your app
* app.useGlobalFilters(new ORPCExceptionFilter())
*
* // Or register as a provider
* @Module({
* providers: [
* {
* provide: APP_FILTER,
* useClass: ORPCExceptionFilter,
* },
* ],
* })
* export class AppModule {}
* ```
*/
@Catch(ORPCError)
@Injectable()
export class ORPCExceptionFilter implements ExceptionFilter {
constructor(
private config?: StandardServerNode.SendStandardResponseOptions | undefined,
) {}

async catch(exception: ORPCError<any, any>, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const res = ctx.getResponse<Response | FastifyReply>()
const standardResponse = codec.encodeError(exception)
// Send the response directly with proper status and headers
const isFastify = 'raw' in res
if (isFastify) {
await StandardServerFastify.sendStandardResponse(res as FastifyReply, standardResponse, this.config)
}
else {
await StandardServerNode.sendStandardResponse(res as Response, standardResponse, this.config)
}
}
}
27 changes: 20 additions & 7 deletions packages/nest/src/implement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ import type { Request } from 'express'
import type { FastifyReply } from 'fastify'
import FastifyCookie from '@fastify/cookie'
import { Controller, Req, Res } from '@nestjs/common'
import { REQUEST } from '@nestjs/core'
import { APP_FILTER, REQUEST } from '@nestjs/core'
import { FastifyAdapter } from '@nestjs/platform-fastify'
import { Test } from '@nestjs/testing'
import { oc, ORPCError } from '@orpc/contract'
import { implement, lazy } from '@orpc/server'
import * as StandardServerNode from '@orpc/standard-server-node'
import supertest from 'supertest'
import { expect, it, vi } from 'vitest'
import * as z from 'zod'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { ORPCExceptionFilter } from './filters/orpc-exception.filter'
import { Implement } from './implement'
import { ORPCModule } from './module'
import * as Utils from './utils'

const sendStandardResponseSpy = vi.spyOn(StandardServerNode, 'sendStandardResponse')
const setStandardResponseSpy = vi.spyOn(Utils, 'setStandardNodeResponse')

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -134,6 +137,14 @@ describe('@Implement', async () => {
] as const)('type: $1', async (Controller, _) => {
const moduleRef = await Test.createTestingModule({
controllers: [Controller],
// The pong test throw errors and need this filter to match the
// expected behaviour.
providers: [
{
provide: APP_FILTER,
useClass: ORPCExceptionFilter,
},
],
}).compile()

const app = moduleRef.createNestApplication()
Expand Down Expand Up @@ -186,6 +197,7 @@ describe('@Implement', async () => {
name: 'world',
},
}))
expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1)

expect(req).toBeDefined()
expect(req!.method).toEqual('GET')
Expand Down Expand Up @@ -369,6 +381,7 @@ describe('@Implement', async () => {
}).compile()

const app = moduleRef.createNestApplication()
app.useGlobalFilters(new ORPCExceptionFilter())
await app.init()

const httpServer = app.getHttpServer()
Expand Down Expand Up @@ -411,8 +424,8 @@ describe('@Implement', async () => {
expect(res.body).toEqual('pong')

expect(interceptor).toHaveBeenCalledTimes(1)
expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1)
expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
expect(setStandardResponseSpy).toHaveBeenCalledTimes(1)
expect(setStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
eventIteratorKeepAliveComment: '__TEST__',
}))
})
Expand Down Expand Up @@ -459,8 +472,8 @@ describe('@Implement', async () => {
}),
}),
}))
expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1)
expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
expect(setStandardResponseSpy).toHaveBeenCalledTimes(1)
expect(setStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
eventIteratorKeepAliveComment: '__TEST__',
}))
})
Expand Down
61 changes: 37 additions & 24 deletions packages/nest/src/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify'
import type { Observable } from 'rxjs'
import type { ORPCModuleConfig } from './module'
import { applyDecorators, Delete, Get, Head, Inject, Injectable, Optional, Patch, Post, Put, UseInterceptors } from '@nestjs/common'
import { toORPCError } from '@orpc/client'
import { fallbackContractConfig, isContractProcedure } from '@orpc/contract'
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
import { StandardOpenAPICodec } from '@orpc/openapi/standard'
Expand All @@ -20,7 +19,7 @@ import * as StandardServerFastify from '@orpc/standard-server-fastify'
import * as StandardServerNode from '@orpc/standard-server-node'
import { mergeMap } from 'rxjs'
import { ORPC_MODULE_CONFIG_SYMBOL } from './module'
import { toNestPattern } from './utils'
import { setStandardFastifyResponse, setStandardNodeResponse, toNestPattern } from './utils'

const MethodDecoratorMap = {
HEAD: Head,
Expand Down Expand Up @@ -124,40 +123,54 @@ export class ImplementInterceptor implements NestInterceptor {
? StandardServerFastify.toStandardLazyRequest(req, res as FastifyReply)
: StandardServerNode.toStandardLazyRequest(req, res as Response)

const standardResponse: StandardResponse = await (async () => {
let isDecoding = false
const client = createProcedureClient(procedure, this.config)

const standardResponse: StandardResponse = await (async (): Promise<StandardResponse> => {
// Decode input - catch only non-ORPC decoding errors and convert to ORPCError
let input: Awaited<ReturnType<typeof codec.decode>>
try {
const client = createProcedureClient(procedure, this.config)

isDecoding = true
const input = await codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure)
isDecoding = false
input = await codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure)
}
catch (e: any) {
let error: ORPCError<any, any> = e
// Malformed request - wrap in ORPCError and let exception filters handle it
if (!(e instanceof ORPCError)) {
error = new ORPCError('BAD_REQUEST', {
message: `Malformed request. Ensure the request body is properly formatted and the 'Content-Type' header is set correctly.`,
cause: e,
})
}
return codec.encodeError(error)
}

const output = await client(input, {
signal: standardRequest.signal,
lastEventId: flattenHeader(standardRequest.headers['last-event-id']),
})
// Execute handler - let all errors bubble up to NestJS exception filters
const output = await client(input, {
signal: standardRequest.signal,
lastEventId: flattenHeader(standardRequest.headers['last-event-id']),
})

// Encode output - catch only non-ORPC encoding errors and convert to ORPCError
try {
return codec.encode(output, procedure)
}
catch (e) {
const error = isDecoding && !(e instanceof ORPCError)
? new ORPCError('BAD_REQUEST', {
message: `Malformed request. Ensure the request body is properly formatted and the 'Content-Type' header is set correctly.`,
cause: e,
})
: toORPCError(e)

catch (e: any) {
let error: ORPCError<any, any> = e
// Encoding error means our handler returned invalid data
if (!(e instanceof ORPCError)) {
error = new ORPCError('INTERNAL_SERVER_ERROR', {
message: `Failed to encode response. The handler may have returned data that doesn't match the contract output schema.`,
cause: e,
})
}
return codec.encodeError(error)
}
})()

// Set status and headers
if ('raw' in res) {
await StandardServerFastify.sendStandardResponse(res, standardResponse, this.config)
return setStandardFastifyResponse(res as FastifyReply, standardResponse, this.config)
}
else {
await StandardServerNode.sendStandardResponse(res, standardResponse, this.config)
return setStandardNodeResponse(res as Response, standardResponse, this.config)
}
}),
)
Expand Down
1 change: 1 addition & 0 deletions packages/nest/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './filters/orpc-exception.filter'
export * from './implement'
export { Implement as Impl } from './implement'
export * from './module'
Expand Down
Loading
Loading