diff --git a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md index 3fc378e55..40412e48a 100644 --- a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md +++ b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md @@ -220,13 +220,23 @@ oRPC will use NestJS parsed body when it's available, and only use the oRPC pars Configure the `@orpc/nest` module by importing `ORPCModule` in your NestJS application: ```ts +import { Module } from '@nestjs/common' import { REQUEST } from '@nestjs/core' import { onError, ORPCModule } from '@orpc/nest' import { Request } from 'express' // if you use express adapter +declare module '@orpc/nest' { + /** + * Extend oRPC global context to make it type-safe inside your handlers/middlewares + */ + interface ORPCGlobalContext { + request: Request + } +} + @Module({ imports: [ - ORPCModule.forRootAsync({ // or .forRoot + ORPCModule.forRootAsync({ // or use .forRoot for static config useFactory: (request: Request) => ({ interceptors: [ onError((error) => { @@ -235,6 +245,8 @@ import { Request } from 'express' // if you use express adapter ], context: { request }, // oRPC context, accessible from middlewares, etc. eventIteratorKeepAliveInterval: 5000, // 5 seconds + customJsonSerializers: [], + plugins: [], // most oRPC plugins are compatible }), inject: [REQUEST], }), @@ -274,3 +286,47 @@ const client: JsonifiedClient> = createORP ::: info Please refer to the [OpenAPILink](/docs/openapi/client/openapi-link) documentation for more information on client setup and options. ::: + +## Advanced + +### Custom Send Response + +By default, oRPC sends the response directly without returning it to the NestJS handler. However, you may want to preserve the return behavior for compatibility with certain NestJS features or third-party libraries. + +```ts +import { Module } from '@nestjs/common' +import { ORPCModule } from '@orpc/nest' +import { Response } from 'express' // if you use express adapter +import { isObject } from '@orpc/shared' // checks if value is a plain object (not a class instance) + +@Module({ + imports: [ + ORPCModule.forRoot({ + sendResponseInterceptors: [ + async ({ response, standardResponse, next }) => { + if ( + standardResponse.status < 200 + || standardResponse.status >= 300 + || !(isObject(standardResponse.body) || Array.isArray(standardResponse.body)) + ) { + // Only object and array are valid to return as response body + // the rest should fallback to default oRPC behavior + return next() + } + + const expressResponse = response as Response + expressResponse.status(standardResponse.status) + for (const [key, value] of Object.entries(standardResponse.headers)) { + if (value !== undefined) { + expressResponse.setHeader(key, value) + } + } + + return standardResponse.body + }, + ], + }), + ], +}) +export class AppModule {} +``` diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index 9dd5f9185..eb4c0d0d2 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -10,7 +10,7 @@ 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 { beforeEach, describe, expect, it, vi } from 'vitest' import * as z from 'zod' import { Implement } from './implement' import { ORPCModule } from './module' @@ -48,6 +48,7 @@ describe('@Implement', async () => { peng: oc.route({ path: '/{+path}', method: 'DELETE', + successStatus: 202, }).input(z.object({ path: z.string(), })), @@ -198,7 +199,7 @@ describe('@Implement', async () => { it('case: call peng', async () => { const res = await supertest(httpServer).delete('/world/who%3F') - expect(res.statusCode).toEqual(200) + expect(res.statusCode).toEqual(202) expect(res.body).toEqual('peng world/who?') expect(peng_handler).toHaveBeenCalledTimes(1) @@ -212,6 +213,17 @@ describe('@Implement', async () => { expect(req!.method).toEqual('DELETE') expect(req!.url).toEqual('/world/who%3F') }) + + it('support dynamic success status', async () => { + ping_handler.mockResolvedValueOnce({ body: 'pong', headers: { 'x-ping': 'pong' }, status: 203 } as any) + + const res = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) + + expect(res.statusCode).toEqual(203) + }) }) it('can avoid conflict method name', async () => { @@ -385,120 +397,315 @@ describe('@Implement', async () => { ]) }) - it('works with ORPCModule.forRoot', async () => { - const interceptor = vi.fn(({ next }) => next()) - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRoot({ - interceptors: [interceptor], - eventIteratorKeepAliveComment: '__TEST__', - }), - ], - controllers: [ImplProcedureController], - }).compile() + describe('module configuration', () => { + it('works with ORPCModule.forRoot', async () => { + const interceptor = vi.fn(({ next }) => next()) + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + interceptors: [interceptor], + eventIteratorKeepAliveComment: '__TEST__', + customJsonSerializers: [ + { + condition: data => data === 'pong', + serialize: () => '__PONG__', + }, + ], + }), + ], + controllers: [ImplProcedureController], + }).compile() - const app = moduleRef.createNestApplication() - await app.init() + const app = moduleRef.createNestApplication() + await app.init() - const httpServer = app.getHttpServer() + const httpServer = app.getHttpServer() - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) + const res = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('pong') + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual('__PONG__') - expect(interceptor).toHaveBeenCalledTimes(1) - expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) - expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ - eventIteratorKeepAliveComment: '__TEST__', - })) - }) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ + eventIteratorKeepAliveComment: '__TEST__', + })) + }) - it('works with ORPCModule.forRootAsync', async () => { - const interceptor = vi.fn(({ next }) => next()) - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRootAsync({ - useFactory: async (request: Request) => ({ - interceptors: [interceptor], - eventIteratorKeepAliveComment: '__TEST__', - context: { - request, - }, + it('works with ORPCModule.forRootAsync', async () => { + const interceptor = vi.fn(({ next }) => next()) + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRootAsync({ + useFactory: async (request: Request) => ({ + interceptors: [interceptor], + eventIteratorKeepAliveComment: '__TEST__', + context: { + request, + }, + customJsonSerializers: [ + { + condition: data => data === 'pong', + serialize: () => '__PONG__', + }, + ], + }), + inject: [REQUEST], }), - inject: [REQUEST], - }), - ], - controllers: [ImplProcedureController], - }).compile() + ], + controllers: [ImplProcedureController], + }).compile() - const app = moduleRef.createNestApplication() - await app.init() + const app = moduleRef.createNestApplication() + await app.init() - const httpServer = app.getHttpServer() + const httpServer = app.getHttpServer() - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) + const res = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('pong') + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual('__PONG__') + + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + request: expect.objectContaining({ + url: '/ping?param=value¶m2[]=value2¶m2[]=value3', + headers: expect.objectContaining({ + 'x-custom': 'value', + }), + }), + }), + })) + expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ + eventIteratorKeepAliveComment: '__TEST__', + })) + }) - expect(interceptor).toHaveBeenCalledTimes(1) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - request: expect.objectContaining({ - url: '/ping?param=value¶m2[]=value2¶m2[]=value3', - headers: expect.objectContaining({ - 'x-custom': 'value', + describe('sendResponseInterceptors', () => { + it('can override response with default status', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + sendResponseInterceptors: [ + async ({ standardResponse }) => { + expect(standardResponse.status).toBe(202) + + return { custom: true } + }, + ], + }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + + const httpServer = app.getHttpServer() + + const res = await supertest(httpServer).delete('/world/who%3F') + + expect(res.statusCode).toEqual(202) + expect(res.text).toEqual('{"custom":true}') + }) + + it('can override response with any status', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + sendResponseInterceptors: [ + async ({ standardResponse, response }) => { + expect(standardResponse.status).toBe(200) + expect(standardResponse.body).toBe('pong') + + response.status(202) + return { custom: true } + }, + ], + }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + + const httpServer = app.getHttpServer() + + const res = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) + + expect(res.statusCode).toEqual(202) + expect(res.text).toEqual('{"custom":true}') + }) + }) + + it('plugins', async () => { + const clientInterceptor = vi.fn(({ next }) => next()) + const clientInterceptors = [clientInterceptor] + const interceptor = vi.fn(({ next }) => next()) + const plugins = [{ + init(options: any) { + options.clientInterceptors ??= [] + options.clientInterceptors.push(interceptor) + }, + }] + + // Use this clientInterceptors, plugins arrays + // to verify that handler options is cloned before applying plugins. + // Without proper cloning, interceptors would run multiple times and affect subsequent requests. + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + context: { thisIsContext: true }, + interceptors: clientInterceptors, + path: ['__PATH__'], + plugins, }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + const httpServer = app.getHttpServer() + + const res1 = await supertest(httpServer).delete('/world/who%3F') + expect(res1.statusCode).toEqual(202) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, }), - }), - })) - expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) - expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ - eventIteratorKeepAliveComment: '__TEST__', - })) + path: ['__PATH__'], + })) + expect(clientInterceptor).toHaveBeenCalledTimes(1) + expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + + interceptor.mockClear() + clientInterceptor.mockClear() + const res2 = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) + expect(res2.statusCode).toEqual(200) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + expect(clientInterceptor).toHaveBeenCalledTimes(1) + expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + + interceptor.mockClear() + clientInterceptor.mockClear() + const res3 = await supertest(httpServer).delete('/world/who%3F') + expect(res3.statusCode).toEqual(202) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + expect(clientInterceptor).toHaveBeenCalledTimes(1) + expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + }) + + it('custom error response body encoder', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + customErrorResponseBodyEncoder: error => ({ + custom: true, + code: error.code, + data: error.data, + }), + }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + + const httpServer = app.getHttpServer() + + const res = await supertest(httpServer).get('/pong/world') + + expect(res.statusCode).toEqual(408) + expect(res.body).toEqual({ + custom: true, + code: 'TEST', + data: 'pong world', + }) + }) }) - it('work with fastify/cookie', async () => { - @Controller() - class FastifyController { - @Implement(contract.ping) - pong(@Res({ passthrough: true }) reply: FastifyReply) { - reply.cookie('foo', 'bar') - return implement(contract.ping).handler(ping_handler) + describe('compatibility', () => { + it('work with fastify/cookie', async () => { + @Controller() + class FastifyController { + @Implement(contract.ping) + pong(@Res({ passthrough: true }) reply: FastifyReply) { + reply.cookie('foo', 'bar') + return implement(contract.ping).handler(ping_handler) + } } - } - const moduleRef = await Test.createTestingModule({ - controllers: [FastifyController], - }).compile() + const moduleRef = await Test.createTestingModule({ + controllers: [FastifyController], + }).compile() - const adapter = new FastifyAdapter() - await adapter.register(FastifyCookie as any) - const app = moduleRef.createNestApplication(adapter) - await app.init() - await app.getHttpAdapter().getInstance().ready() + const adapter = new FastifyAdapter() + await adapter.register(FastifyCookie as any) + const app = moduleRef.createNestApplication(adapter) + await app.init() + await app.getHttpAdapter().getInstance().ready() - const httpServer = app.getHttpServer() + const httpServer = app.getHttpServer() - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) + const res = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('pong') - expect(res.headers).toEqual(expect.objectContaining({ - 'x-ping': 'pong', - 'set-cookie': [ - expect.stringContaining('foo=bar'), - ], - })) + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual('pong') + expect(res.headers).toEqual(expect.objectContaining({ + 'x-ping': 'pong', + 'set-cookie': [ + expect.stringContaining('foo=bar'), + ], + })) + }) }) }) diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index 0bc923fbe..c139d7170 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -3,19 +3,17 @@ import type { ContractRouter } from '@orpc/contract' import type { Router } from '@orpc/server' import type { StandardParams } from '@orpc/server/standard' import type { Promisable } from '@orpc/shared' -import type { StandardResponse } from '@orpc/standard-server' import type { Request, Response } from 'express' 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 type { ORPCGlobalContext, ORPCModuleConfig } from './module' +import { applyDecorators, Delete, Get, Head, HttpCode, Inject, Injectable, Optional, Patch, Post, Put, UseInterceptors } from '@nestjs/common' import { fallbackContractConfig, isContractProcedure } from '@orpc/contract' import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard' import { StandardOpenAPICodec } from '@orpc/openapi/standard' -import { createProcedureClient, getRouter, isProcedure, ORPCError, unlazy } from '@orpc/server' -import { get } from '@orpc/shared' -import { flattenHeader } from '@orpc/standard-server' +import { getRouter, isProcedure, unlazy } from '@orpc/server' +import { StandardHandler } from '@orpc/server/standard' +import { get, intercept, toArray } from '@orpc/shared' import * as StandardServerFastify from '@orpc/standard-server-fastify' import * as StandardServerNode from '@orpc/standard-server-node' import { mergeMap } from 'rxjs' @@ -38,7 +36,7 @@ const MethodDecoratorMap = { */ export function Implement>( contract: T, -): >>>( +): >>( target: Record, propertyKey: string, descriptor: TypedPropertyDescriptor<(...args: any[]) => U>, @@ -58,6 +56,7 @@ export function Implement>( return (target, propertyKey, descriptor) => { applyDecorators( MethodDecoratorMap[method](toNestPattern(path)), + HttpCode(fallbackContractConfig('defaultSuccessStatus', contract['~orpc'].route.successStatus)), UseInterceptors(ImplementInterceptor), )(target, propertyKey, descriptor) } @@ -90,20 +89,26 @@ export function Implement>( } } -const codec = new StandardOpenAPICodec( - new StandardOpenAPISerializer( - new StandardOpenAPIJsonSerializer(), - new StandardBracketNotationSerializer(), - ), -) - type NestParams = Record @Injectable() export class ImplementInterceptor implements NestInterceptor { + private readonly config: Partial + private readonly codec: StandardOpenAPICodec + constructor( - @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() private readonly config: ORPCModuleConfig | undefined, + @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() config: ORPCModuleConfig | undefined, ) { + // @Optional() does not allow set default value so we need to do it here + this.config = config ?? {} + + this.codec = new StandardOpenAPICodec( + new StandardOpenAPISerializer( + new StandardOpenAPIJsonSerializer(this.config), + new StandardBracketNotationSerializer(this.config), + ), + this.config, + ) } intercept(ctx: ExecutionContext, next: CallHandler): Observable { @@ -124,40 +129,33 @@ 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 - - try { - const client = createProcedureClient(procedure, this.config) - - isDecoding = true - const input = await codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure) - isDecoding = false - - const output = await client(input, { - signal: standardRequest.signal, - lastEventId: flattenHeader(standardRequest.headers['last-event-id']), - }) - - 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) - - return codec.encodeError(error) - } - })() - - if ('raw' in res) { - await StandardServerFastify.sendStandardResponse(res, standardResponse, this.config) - } - else { - await StandardServerNode.sendStandardResponse(res, standardResponse, this.config) + const handler = new StandardHandler(procedure, { + init: () => {}, + match: () => Promise.resolve({ path: toArray(this.config.path), procedure, params: flattenParams(req.params as NestParams) }), + }, this.codec, { + // Since plugins can modify options directly, so we need to clone to avoid affecting other handlers/requests + // TODO: improve plugins system to avoid this cloning + clientInterceptors: [...toArray(this.config.interceptors)], + plugins: [...toArray(this.config.plugins)], + }) + + const result = await handler.handle(standardRequest, { + context: this.config.context, + }) + + if (result.matched) { + return intercept( + toArray(this.config.sendResponseInterceptors), + { request: req, response: res, standardResponse: result.response }, + async ({ response, standardResponse }) => { + if ('raw' in response) { + await StandardServerFastify.sendStandardResponse(response, standardResponse, this.config) + } + else { + await StandardServerNode.sendStandardResponse(response, standardResponse, this.config) + } + }, + ) } }), ) diff --git a/packages/nest/src/index.test.ts b/packages/nest/src/index.test.ts new file mode 100644 index 000000000..8f0a9e309 --- /dev/null +++ b/packages/nest/src/index.test.ts @@ -0,0 +1,21 @@ +import * as ServerModule from '@orpc/server' +import { expect, it, vi } from 'vitest' +import { implement } from './index' + +vi.mock('@orpc/server', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + implement: vi.fn(original.implement), + } +}) + +it('implement is aliased', () => { + const contract = { nested: {} } + const options = { dedupeLeadingMiddlewares: false } + const impl = implement(contract, options) + + expect(ServerModule.implement).toHaveBeenCalledTimes(1) + expect(ServerModule.implement).toHaveBeenCalledWith(contract, options) + expect(impl).toBe(vi.mocked(ServerModule.implement).mock.results[0]!.value) +}) diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 1681f48c8..364742e65 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -1,9 +1,14 @@ +import type { AnyContractRouter } from '@orpc/contract' +import type { BuilderConfig, Context, Implementer } from '@orpc/server' +import type { ORPCGlobalContext } from './module' +import { implement as baseImplement } from '@orpc/server' + export * from './implement' export { Implement as Impl } from './implement' export * from './module' export * from './utils' -export { implement, onError, onFinish, onStart, onSuccess, ORPCError } from '@orpc/server' +export { onError, onFinish, onStart, onSuccess, ORPCError } from '@orpc/server' export type { ImplementedProcedure, Implementer, @@ -13,3 +18,13 @@ export type { RouterImplementer, RouterImplementerWithMiddlewares, } from '@orpc/server' + +/** + * Alias for `implement` from `@orpc/server` with default context set to `ORPCGlobalContext` + */ +export function implement( + contract: T, + config: BuilderConfig = {}, +): Implementer { + return baseImplement(contract, config) +} diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index eb78e234d..fc913b7ca 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -1,15 +1,44 @@ import type { DynamicModule } from '@nestjs/common' import type { AnySchema } from '@orpc/contract' +import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' +import type { StandardOpenAPICodecOptions } from '@orpc/openapi/standard' import type { CreateProcedureClientOptions } from '@orpc/server' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { Interceptor } from '@orpc/shared' +import type { StandardResponse } from '@orpc/standard-server' import type { SendStandardResponseOptions } from '@orpc/standard-server-node' import { Module } from '@nestjs/common' import { ImplementInterceptor } from './implement' export const ORPC_MODULE_CONFIG_SYMBOL = Symbol('ORPC_MODULE_CONFIG') +/** + * You can extend this interface to add global context properties. + * @example + * ```ts + * declare module '@orpc/nest' { + * interface ORPCGlobalContext { + * user: { id: string; name: string } + * } + * } + * ``` + */ +export interface ORPCGlobalContext { + +} +// TODO: replace CreateProcedureClientOptions with StandardHandlerOptions export interface ORPCModuleConfig extends - CreateProcedureClientOptions, - SendStandardResponseOptions { + CreateProcedureClientOptions, + SendStandardResponseOptions, + StandardOpenAPIJsonSerializerOptions, + StandardBracketNotationSerializerOptions, + StandardOpenAPICodecOptions { + plugins?: StandardHandlerOptions['plugins'] + + sendResponseInterceptors?: Interceptor< + { request: any, response: any, standardResponse: StandardResponse }, + unknown + >[] } @Module({}) diff --git a/playgrounds/nest/package.json b/playgrounds/nest/package.json index 145e7919c..af68720d9 100644 --- a/playgrounds/nest/package.json +++ b/playgrounds/nest/package.json @@ -15,6 +15,7 @@ "@nestjs/schematics": "^11.0.9", "@orpc/client": "next", "@orpc/contract": "next", + "@orpc/json-schema": "next", "@orpc/nest": "next", "@orpc/openapi": "next", "@orpc/openapi-client": "next", diff --git a/playgrounds/nest/src/app.module.ts b/playgrounds/nest/src/app.module.ts index 37742fb4c..7e1f13493 100644 --- a/playgrounds/nest/src/app.module.ts +++ b/playgrounds/nest/src/app.module.ts @@ -6,16 +6,41 @@ import { PlanetService } from './planet/planet.service' import { ReferenceController } from './reference/reference.controller' import { ReferenceService } from './reference/reference.service' import { onError, ORPCModule } from '@orpc/nest' +import { REQUEST } from '@nestjs/core' +import { experimental_SmartCoercionPlugin as SmartCoercionPlugin } from '@orpc/json-schema' +import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' +import { Request } from 'express' + +declare module '@orpc/nest' { + /** + * Extend oRPC global context to make it type-safe inside your handlers/middlewares + */ + interface ORPCGlobalContext { + request: Request + } +} @Module({ imports: [ - ORPCModule.forRoot({ - interceptors: [ - onError((error) => { - console.error(error) - }), - ], - eventIteratorKeepAliveInterval: 5000, // 5 seconds + ORPCModule.forRootAsync({ // or use .forRoot for static config + useFactory: (request: Request) => ({ + interceptors: [ + onError((error) => { + console.error(error) + }), + ], + context: { request }, // oRPC context, accessible from middlewares, etc. + eventIteratorKeepAliveInterval: 5000, // 5 seconds + customJsonSerializers: [], + plugins: [ + new SmartCoercionPlugin({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], + }), + ], // almost oRPC plugins are compatible + }), + inject: [REQUEST], }), ], controllers: [AuthController, PlanetController, ReferenceController, OtherController], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb123666e..cace1d2d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1384,6 +1384,9 @@ importers: '@orpc/contract': specifier: next version: link:../../packages/contract + '@orpc/json-schema': + specifier: next + version: link:../../packages/json-schema '@orpc/nest': specifier: next version: link:../../packages/nest