diff --git a/package-lock.json b/package-lock.json index b2ccc44d..78f33944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ }, "peerDependencies": { "@hapi/hapi": ">= 20.0.0", + "@trpc/server": ">= 9.0.0", "@types/aws-lambda": "^8.10.92", "@types/express": ">= 4.15.4", "@types/hapi": ">= 18.0.7", @@ -1820,6 +1821,12 @@ "node": ">= 6" } }, + "node_modules/@trpc/server": { + "version": "9.25.3", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-9.25.3.tgz", + "integrity": "sha512-1LEURCmSGpxx8/0ZtzMpd0hiSB4xGNKJ9DKq6sbPk+bzdACjH9Ujdwca4P6ylxa8HdMel/effwjlCG0Tfx06Lg==", + "peer": true + }, "node_modules/@tsconfig/node10": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", @@ -15187,6 +15194,12 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true }, + "@trpc/server": { + "version": "9.25.3", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-9.25.3.tgz", + "integrity": "sha512-1LEURCmSGpxx8/0ZtzMpd0hiSB4xGNKJ9DKq6sbPk+bzdACjH9Ujdwca4P6ylxa8HdMel/effwjlCG0Tfx06Lg==", + "peer": true + }, "@tsconfig/node10": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", diff --git a/package.json b/package.json index 03e86d7a..7bdbc7c7 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "node.js", "http", "huawei", - "functiongraph" + "functiongraph", + "trpc" ], "bugs": { "url": "https://github.com/H4ad/serverless-adapter/issues" @@ -97,7 +98,8 @@ }, "peerDependencies": { "@hapi/hapi": ">= 20.0.0", - "@types/aws-lambda": "^8.10.92", + "@trpc/server": ">= 9.0.0", + "@types/aws-lambda": ">= 8.10.92", "@types/express": ">= 4.15.4", "@types/hapi": ">= 18.0.7", "@types/koa": ">= 2.11.2", @@ -107,10 +109,13 @@ "koa": ">= 2.5.1" }, "peerDependenciesMeta": { - "@types/aws-lambda": { + "@hapi/hapi": { "optional": true }, - "@hapi/hapi": { + "@trpc/server": { + "optional": true + }, + "@types/aws-lambda": { "optional": true }, "express": { diff --git a/src/frameworks/trpc/index.ts b/src/frameworks/trpc/index.ts new file mode 100644 index 00000000..b8db6d28 --- /dev/null +++ b/src/frameworks/trpc/index.ts @@ -0,0 +1 @@ +export * from './trpc.framework'; diff --git a/src/frameworks/trpc/trpc.framework.ts b/src/frameworks/trpc/trpc.framework.ts new file mode 100644 index 00000000..a45ba4f2 --- /dev/null +++ b/src/frameworks/trpc/trpc.framework.ts @@ -0,0 +1,296 @@ +//#region + +import { IncomingMessage, ServerResponse } from 'http'; +import type { AnyRouter, DataTransformer } from '@trpc/server'; +import { + NodeHTTPCreateContextFn, + NodeHTTPCreateContextFnOptions, + NodeHTTPHandlerOptions, + nodeHTTPRequestHandler, +} from '@trpc/server/adapters/node-http'; +import { SingleValueHeaders } from '../../@types'; +import { FrameworkContract } from '../../contracts'; +import { getDefaultIfUndefined, getFlattenedHeadersMap } from '../../core'; + +//#endregion + +/** + * The transformer that is responsible to transform buffer's input to javascript objects + * + * @breadcrumb Frameworks / TrpcFramework + * @public + */ +export class BufferToJSObjectTransformer implements DataTransformer { + /** + * Deserialize unknown values to javascript objects + * + * @param value - The value to be deserialized + */ + public deserialize(value?: unknown): any { + if (value instanceof Buffer) return JSON.parse(value.toString('utf-8')); + + return value; + } + + /** + * The value to be serialized, do nothing because tRPC can handle. + * + * @param value - The value to be serialized + */ + public serialize(value: any): any { + return value; + } +} + +/** + * The context created by this library that allows getting some information from the request and setting the status and header of the response. + * + * @breadcrumb Frameworks / TrpcFramework + * @public + */ +export interface TrpcAdapterBaseContext { + /** + * The request object that will be forward to your app + */ + request: IncomingMessage; + + /** + * The response object that will be forward to your app to output the response + */ + response: ServerResponse; + + /** + * The method to set response status. + * + * @param statusCode - The response status that you want + */ + setStatus(statusCode: number): void; + + /** + * The method to set some header in the response + * + * @param name - The name of the header + * @param value - The value to be set in the header + */ + setHeader(name: string, value: number | string): void; + + /** + * The method to remove some header from the response + * + * @param name - The name of the header + */ + removeHeader(name: string): void; + + /** + * The method to return the value of some header from the request + * + * @param name - The name of the header + */ + getHeader(name: string): string | undefined; + + /** + * The method to return the request headers + */ + getHeaders(): SingleValueHeaders; + + /** + * The method to return user's address + */ + getIp(): string | undefined; + + /** + * The method to return the URL called + */ + getUrl(): string | undefined; + + /** + * The method to return the HTTP Method in the request + */ + getMethod(): string | undefined; +} + +/** + * This is the context merged between {@link TrpcAdapterBaseContext} and the {@link TContext} that you provided. + * + * This context will be merged with the context you created with `createContext` inside {@link TrpcFrameworkOptions}. + * So to make the type work, just send the properties you've added inside {@link TContext}. + * + * @example + * ```typescript + * type MyCustomContext = { user: { name: string } }; + * type TrpcContext = TrpcAdapterContext; // your final context type to put inside trpc.router + * ``` + * + * @breadcrumb Frameworks / TrpcFramework + * @public + */ +export type TrpcAdapterContext = TContext & TrpcAdapterBaseContext; + +/** + * The options to customize the {@link TrpcFramework} + * + * @breadcrumb Frameworks / TrpcFramework + * @public + */ +export type TrpcFrameworkOptions = Omit< + NodeHTTPHandlerOptions, IncomingMessage, ServerResponse>, + 'router' | 'createContext' +> & { + createContext?: ( + opts: NodeHTTPCreateContextFnOptions, + ) => + | Omit + | Promise>; +}; + +/** + * The framework that forwards requests to TRPC handler + * + * @breadcrumb Frameworks / TrpcFramework + * @public + */ +export class TrpcFramework + implements FrameworkContract> +{ + //#region Constructor + + /** + * Default constructor + */ + constructor(protected readonly options?: TrpcFrameworkOptions) {} + + //#endregion + + //#region Public Methods + + /** + * {@inheritDoc} + */ + public sendRequest( + app: AnyRouter, + request: IncomingMessage, + response: ServerResponse, + ): void { + const endpoint = this.getSafeUrlForTrpc(request); + + nodeHTTPRequestHandler({ + req: request, + res: response, + path: endpoint, + router: app, + ...this.options, + createContext: createContextOptions => + this.mergeDefaultContextWithOptionsContext(createContextOptions), + }); + } + + //#endregion + + //#region Protected Methods + + /** + * Get safe url that can be used inside Trpc. + * + * @example + * ```typescript + * const url = getSafeUrlForTrpc('/users?input=hello'); + * console.log(url); // users + * ``` + * + * @param request - The request object that will be forward to your app + */ + protected getSafeUrlForTrpc(request: IncomingMessage): string { + let url = request.url!; + + if (url.startsWith('/')) url = url.slice(1); + + if (url.includes('?')) url = url.split('?')[0]; + + return url; + } + + /** + * Merge the default context ({@link TrpcAdapterContext}) with the context created by the user. + * + * @param createContextOptions - The options sent by trpc to create the context + */ + protected mergeDefaultContextWithOptionsContext( + createContextOptions: NodeHTTPCreateContextFnOptions< + IncomingMessage, + ServerResponse + >, + ): TContext | Promise { + const createContextFromOptions: NodeHTTPCreateContextFn< + AnyRouter>, + IncomingMessage, + ServerResponse + > = getDefaultIfUndefined( + this.options?.createContext, + () => + undefined as unknown as Omit, + ); + + const resolvedContext = createContextFromOptions(createContextOptions); + + if (resolvedContext && resolvedContext.then) { + return resolvedContext.then(context => + this.wrapResolvedContextWithDefaultContext( + context, + createContextOptions, + ), + ); + } + + return this.wrapResolvedContextWithDefaultContext( + resolvedContext, + createContextOptions, + ); + } + + /** + * Wraps the resolved context from the {@link TrpcFrameworkOptions} created with `createContext` and merge + * with the {@link TrpcAdapterContext} generated by the library. + * + * @param resolvedContext - The context created with `createContext` inside {@link TrpcFrameworkOptions} + * @param createContextOptions - The options sent by trpc to create the context + */ + protected wrapResolvedContextWithDefaultContext( + resolvedContext: TContext, + createContextOptions: NodeHTTPCreateContextFnOptions< + IncomingMessage, + ServerResponse + >, + ): TContext { + const request = createContextOptions.req; + const response = createContextOptions.res; + + return { + ...resolvedContext, + request, + response, + getUrl: () => request.url, + getMethod: () => request.method, + getHeaders: () => getFlattenedHeadersMap(request.headers, ',', true), + setHeader: (header, value) => { + response.setHeader(header, value); + }, + removeHeader: header => { + response.removeHeader(header); + }, + getHeader: (header: string) => { + return getFlattenedHeadersMap(request.headers, ',', true)[ + header.toLowerCase() + ]; + }, + setStatus: (statusCode: number) => { + response.statusCode = statusCode; + // force undefined to get default message for the status code + // ref: https://nodejs.org/dist/latest-v16.x/docs/api/http.html#responsestatusmessage + response.statusMessage = undefined as any; + }, + getIp: () => request.connection.remoteAddress, + }; + } + + //#endregion +} diff --git a/src/index.doc.ts b/src/index.doc.ts index da4a52a1..465cc2bd 100644 --- a/src/index.doc.ts +++ b/src/index.doc.ts @@ -10,6 +10,7 @@ export * from './frameworks/fastify'; export * from './frameworks/koa'; export * from './frameworks/hapi'; export * from './frameworks/lazy'; +export * from './frameworks/trpc'; export * from './handlers/default'; export * from './handlers/huawei'; export * from './network'; diff --git a/test/frameworks/koa.framework.spec.ts b/test/frameworks/koa.framework.spec.ts index 7e06cde9..7868978f 100644 --- a/test/frameworks/koa.framework.spec.ts +++ b/test/frameworks/koa.framework.spec.ts @@ -1,11 +1,12 @@ import Application, { Context } from 'koa'; +import { NO_OP } from '../../src'; import { KoaFramework } from '../../src/frameworks/koa'; import { TestRouteBuilderHandler, createTestSuiteFor } from './utils'; function createHandler(): TestRouteBuilderHandler { return (app, path, handler) => { app.use((ctx: Context) => { - const [statusCode, resultBody, headers] = handler(ctx.headers, ctx.body); + const [statusCode, resultBody, headers] = handler(ctx.headers, NO_OP); for (const header of Object.keys(headers)) ctx.set(header, headers[header]); diff --git a/test/frameworks/trpc.framework.spec.ts b/test/frameworks/trpc.framework.spec.ts new file mode 100644 index 00000000..e9fad4cb --- /dev/null +++ b/test/frameworks/trpc.framework.spec.ts @@ -0,0 +1,291 @@ +import * as trpc from '@trpc/server'; +import { AnyRouter } from '@trpc/server'; +import { + NO_OP, + ServerlessRequest, + ServerlessResponse, + getEventBodyAsBuffer, + waitForStreamComplete, +} from '../../src'; +import { + BufferToJSObjectTransformer, + TrpcAdapterContext, + TrpcFramework, +} from '../../src/frameworks/trpc'; +import { TestRouteBuilderHandler, frameworkTestOptions } from './utils'; + +type TrpcContext = TrpcAdapterContext; + +function createHandler( + method: 'get' | 'post' | 'delete' | 'put', +): TestRouteBuilderHandler, AnyRouter> { + return (app, path, handler) => { + if (method === 'get') { + return app.query(path, { + input: inp => inp, + resolve: ({ ctx, input }) => { + const [statusCode, resultBody, headers] = handler( + ctx.getHeaders(), + input, + ); + + for (const header of Object.keys(headers)) + ctx.setHeader(header, headers[header]); + + ctx.setStatus(statusCode); + + return resultBody; + }, + }); + } else { + return app.mutation(path, { + input: inp => inp, + resolve: ({ ctx, input }) => { + const [statusCode, resultBody, headers] = handler( + ctx.getHeaders(), + input, + ); + + for (const header of Object.keys(headers)) + ctx.setHeader(header, headers[header]); + + ctx.setStatus(statusCode); + + return resultBody; + }, + }); + } + }; +} + +function createRouter() { + return trpc.router(); +} + +const validTestOptions = frameworkTestOptions.filter( + ([method]) => method === 'post' || method === 'get', +); + +describe(TrpcFramework.name, () => { + for (const [ + method, + path, + statusCode, + body, + expectedValue, + ] of validTestOptions) { + const formattedPath = path.replace('/', '').replace(/\//g, '.'); + const requestUrl = '/' + formattedPath; + + it(`${method}:${formattedPath}: should forward request and receive response correctly`, async () => { + const app = createRouter(); + const handler = createHandler(method); + + const resolvedApp = handler( + app, + formattedPath, + (requestHeaders, requestBody) => { + expect(requestHeaders).toHaveProperty('request-header', 'true'); + + if ( + (method === 'post' || method === 'put') && + requestBody !== NO_OP + ) { + const parsedRequestBody = + requestBody instanceof Buffer + ? JSON.parse(requestBody.toString('utf-8')) + : requestBody; + + expect(parsedRequestBody).toEqual(body); + } + + return [statusCode, body, { 'response-header': 'true' }]; + }, + ); + + const framework = new TrpcFramework>(); + + const stringBody = body ? JSON.stringify(body) : body; + const [bufferBody, bodyLength] = stringBody + ? getEventBodyAsBuffer(stringBody, false) + : [undefined, 0]; + + const request = new ServerlessRequest({ + method: method.toUpperCase(), + url: + method !== 'get' + ? requestUrl + : requestUrl + `?input=${encodeURIComponent(JSON.stringify(body))}`, + headers: { + 'content-length': String(bodyLength), + 'request-header': 'true', + ...(body && { + 'content-type': 'application/json', + }), + }, + body: bufferBody, + remoteAddress: '1.1.1.1', + }); + + const response = new ServerlessResponse({ + method: method.toUpperCase(), + }); + + framework.sendRequest(resolvedApp, request, response); + + await waitForStreamComplete(response); + + const resultBody = ServerlessResponse.body(response); + + expect(resultBody.toString('utf-8')).toEqual( + expectedValue !== undefined + ? expectedValue + : JSON.stringify({ id: null, result: { type: 'data', data: body } }), + ); + expect(response.statusCode).toBe(statusCode); + expect(ServerlessResponse.headers(response)).toHaveProperty( + 'response-header', + 'true', + ); + }); + } + + it('should enable create custom contexts', async () => { + type Context = { currentDate: Date }; + type CustomContext = TrpcAdapterContext; + + const currentDate = new Date(); + + const app = trpc.router().query('test', { + resolve: function ({ ctx }) { + expect(ctx).toHaveProperty('currentDate'); + + ctx.setStatus(201); + }, + }); + + const request = new ServerlessRequest({ + method: 'GET', + url: '/test', + headers: { + test: 'header', + }, + body: undefined, + remoteAddress: '1.1.1.1', + }); + + const firstResponse = new ServerlessResponse({ + method: 'get', + }); + + const secondResponse = new ServerlessResponse({ + method: 'get', + }); + + const firstFramework = new TrpcFramework>({ + createContext: async () => Promise.resolve({ currentDate }), + }); + + const secondFramework = new TrpcFramework>({ + createContext: () => ({ currentDate }), + }); + + firstFramework.sendRequest(app, request, firstResponse); + secondFramework.sendRequest(app, request, secondResponse); + + await waitForStreamComplete(firstResponse); + await waitForStreamComplete(secondResponse); + + const firstResultBody = ServerlessResponse.body(firstResponse); + const secondResultBody = ServerlessResponse.body(secondResponse); + + const emptyResponse = JSON.stringify({ + id: null, + result: { type: 'data' }, + }); + + expect(firstResultBody.toString('utf-8')).toEqual(emptyResponse); + expect(secondResultBody.toString('utf-8')).toEqual(emptyResponse); + }); + + it('should correctly send default methods inside context', async () => { + const app = createRouter().query('test', { + resolve: function ({ ctx }) { + expect(ctx.request).toBeDefined(); + expect(ctx.response).toBeDefined(); + + expect(ctx.getMethod()).toEqual('GET'); + expect(ctx.getUrl()).toEqual('/test'); + expect(ctx.getIp()).toEqual('1.1.1.1'); + expect(ctx.getHeaders()).toHaveProperty('test', 'header'); + expect(ctx.getHeader('test')).toEqual('header'); + expect(ctx.getMethod()).toEqual('GET'); + + ctx.setStatus(204); + ctx.setHeader('test2', 'batata'); + ctx.removeHeader('test2'); + }, + }); + const framework = new TrpcFramework>(); + + const request = new ServerlessRequest({ + method: 'GET', + url: '/test', + headers: { + test: 'header', + }, + body: undefined, + remoteAddress: '1.1.1.1', + }); + + const response = new ServerlessResponse({ + method: 'get', + }); + + framework.sendRequest(app, request, response); + + await waitForStreamComplete(response); + + const resultBody = ServerlessResponse.body(response); + + expect(resultBody.toString('utf-8')).toEqual(''); + expect(response.statusCode).toEqual(204); + expect(response.headers).not.toHaveProperty('test2'); + }); +}); + +describe(BufferToJSObjectTransformer.name, () => { + it('should correctly parse json when came from buffer', () => { + const jsonObject = { batata: true }; + const testJson = JSON.stringify(jsonObject); + const transformer = new BufferToJSObjectTransformer(); + + const buffer = Buffer.from(testJson, 'utf-8'); + + expect(transformer.deserialize(buffer)).toEqual(jsonObject); + }); + + it('should dont deserialize the value when is not an buffer', () => { + const values = [Symbol('do nothing'), 'test', 434]; + const transformer = new BufferToJSObjectTransformer(); + + for (const value of values) + expect(transformer.deserialize(value)).toEqual(value); + }); + + it('should dont modify the value when serialize', () => { + const symbol = Symbol('do nothing'); + const transformer = new BufferToJSObjectTransformer(); + + expect(transformer.serialize(symbol)).toEqual(symbol); + }); + + it('should throw error when buffer is not an JSON', () => { + const xml = 'true'; + const transformer = new BufferToJSObjectTransformer(); + + const buffer = Buffer.from(xml, 'utf-8'); + + expect(() => transformer.deserialize(buffer)).toThrow(); + }); +}); diff --git a/test/frameworks/utils.ts b/test/frameworks/utils.ts index 4639c5d9..da98bcb0 100644 --- a/test/frameworks/utils.ts +++ b/test/frameworks/utils.ts @@ -1,18 +1,20 @@ import { FrameworkContract, + NO_OP, ServerlessRequest, ServerlessResponse, + getEventBodyAsBuffer, waitForStreamComplete, } from '../../src'; -export type TestRouteBuilderHandler = ( +export type TestRouteBuilderHandler = ( app: TApp, path: string, handler: ( headers: any, body: any, ) => [statusCode: number, body: any, headers: any], -) => void; +) => TOutput; export type TestRouteBuilderMethods = 'get' | 'post' | 'delete' | 'put'; @@ -21,52 +23,76 @@ export type TestRouteBuilder = Record< TestRouteBuilderHandler >; +export const frameworkTestOptions: [ + method: TestRouteBuilderMethods, + path: string, + statusCode: number, + body: any, + expectedValue?: string, +][] = [ + ['get', '/users', 200, [{ name: 'Joga10' }]], + ['get', '/users/list', 200, []], + ['get', '/users/1', 404, { didntFind: 'entity' }], + ['get', '/users/2', 404, { notFound: true }], + ['post', '/empty/route', 204, undefined, ''], + ['post', '/users/error', 401, { unathorized: true }], + ['post', '/users', 201, { success: true }], + ['put', '/users/1', 201, { updated: true }], + ['put', '/users/2', 404, { notFound: true }], + ['put', '/users/3', 404, { didntFind: 'entity' }], + ['delete', '/users/1', 200, { deleted: true }], + ['delete', '/users/noreturn', 204, undefined, ''], + ['delete', '/users/2', 401, { unathorized: true }], + ['get', '/bad-gateway', 503, { error: true }], +]; + export function createTestSuiteFor( frameworkFactory: () => FrameworkContract, appFactory: () => TApp, routeBuilder: TestRouteBuilder, ): void { - it('should forward request and receive response correctly', async () => { - const options: [ - method: TestRouteBuilderMethods, - path: string, - statusCode: number, - body: any, - expectedValue?: string, - ][] = [ - ['get', '/users', 200, [{ name: 'Joga10' }]], - ['get', '/users/list', 200, []], - ['get', '/users/1', 404, { didntFind: 'entity' }], - ['get', '/users/2', 404, { notFound: true }], - ['post', '/empty/route', 204, undefined, ''], - ['post', '/users/error', 401, { unathorized: true }], - ['post', '/users', 201, { success: true }], - ['put', '/users/1', 201, { updated: true }], - ['put', '/users/2', 404, { notFound: true }], - ['put', '/users/3', 404, { didntFind: 'entity' }], - ['delete', '/users/1', 200, { deleted: true }], - ['delete', '/users/noreturn', 204, undefined, ''], - ['delete', '/users/2', 401, { unathorized: true }], - ['get', '/bad-gateway', 503, { error: true }], - ]; - - for (const [method, path, statusCode, body, expectedValue] of options) { + for (const [ + method, + path, + statusCode, + body, + expectedValue, + ] of frameworkTestOptions) { + it(`${method}${path}: should forward request and receive response correctly`, async () => { const app = appFactory(); routeBuilder[method](app, path, (requestHeaders, requestBody) => { - expect(request.headers).toHaveProperty('request-header', 'true'); - expect(requestBody).toEqual(requestBody); + expect(requestHeaders).toHaveProperty('request-header', 'true'); + + if ((method === 'post' || method === 'put') && requestBody !== NO_OP) { + const parsedRequestBody = + requestBody instanceof Buffer + ? JSON.parse(requestBody.toString('utf-8')) + : requestBody; + + expect(parsedRequestBody || null).toEqual(body || null); + } return [statusCode, body, { 'response-header': 'true' }]; }); + const stringBody = body ? JSON.stringify(body) : body; + const [bufferBody, bodyLength] = stringBody + ? getEventBodyAsBuffer(stringBody, false) + : [undefined, 0]; + const framework = frameworkFactory(); const request = new ServerlessRequest({ method: method.toUpperCase(), url: path, headers: { + 'content-length': String(bodyLength), 'request-header': 'true', + ...(body && { + 'content-type': 'application/json', + }), }, + body: bufferBody, }); const response = new ServerlessResponse({ @@ -87,6 +113,6 @@ export function createTestSuiteFor( expect(resultBody.toString('utf-8')).toEqual( expectedValue !== undefined ? expectedValue : JSON.stringify(body), ); - } - }); + }); + } } diff --git a/www/docs/main/frameworks/trpc.mdx b/www/docs/main/frameworks/trpc.mdx new file mode 100644 index 00000000..e144837b --- /dev/null +++ b/www/docs/main/frameworks/trpc.mdx @@ -0,0 +1,174 @@ +--- +title: tRPC +position: 7 +description: See more about how to integrate with tRPC. +--- + +:::info + +The following examples only work with tRPC over HTTP, you cannot use this framework to support websocket or subscriptions. + +::: + +First, you need to ensure you have the libs installed, so run this code: + +```bash +npm i --save @trpc/server +``` + +Then, you need you just need to use the [TrpcFramework](../../api/Frameworks/TrpcFramework) when you create your adapter, like: + +```ts title="index.ts" +import * as trpc from '@trpc/server'; +import { ServerlessAdapter } from '@h4ad/serverless-adapter'; +import { TrpcFramework, TrpcAdapterContext, TrpcFrameworkOptions, BufferToJSObjectTransformer } from '@h4ad/serverless-adapter/lib/frameworks/trpc'; +import { z } from 'zod'; + +type CustomContext = { currentDate: Date }; +type TrpcContext = TrpcAdapterContext; + +const appRouter = trpc + .router() + .transformer(new BufferToJSObjectTransformer()) + .query('getUser', { + input: z.string(), + async resolve(req) { + req.input; // string + return { id: req.input, name: 'Bilbo' }; + }, + }) + .mutation('createUser', { + // validate input with Zod + input: z.object({ name: z.string().min(5) }), + async resolve({ input }) { + return { + created: true, + newName: input.name, + }; + }, + }); + +const frameworkOptions: TrpcFrameworkOptions = { + createContext: () => ({ currentDate: new Date() }), +}; + +const framework = new TrpcFramework(frameworkOptions); + +export const handler = ServerlessAdapter.new(appRouter) + .setFramework(framework) + // continue to set the other options here. + //.setHandler(new DefaultHandler()) + //.setResolver(new PromiseResolver()) + //.addAdapter(new AlbAdapter()) + //.addAdapter(new SQSAdapter()) + //.addAdapter(new SNSAdapter()) + // after put all methods necessary, just call the build method. + .build(); +``` + +:::warning + +Always add the [BufferToJSObjectTransformer](../../api/Frameworks/TrpcFramework/BufferToJSObjectTransformer) because the input can be a buffer +when you sent some JSON inside `body` on mutation, so this transformer will be responsible to convert back to JS Object. + +::: + +:::tip + +Need more examples? See more examples [here](https://github.com/H4ad/serverless-adapter-examples#trpc). + +::: + +## Integrating with Adapters + +This framework is a little special when dealing with request URLs because of the structure of tRPC. + +So when you integrate with an adapter like [SQSAdapter](../../adapters/aws/sqs), when you receive an event, the adapter will forward the request to `/sqs` by default +but because of the structure of tRPC, the framework will change the request URL to just `sqs`. + +With this behavior, to integrate with [SQSAdapter](../../adapters/aws/sqs), you will create the following mutation: + +```typescript title="index.ts" +import type { SQSEvent } from 'aws-lambda'; +import * as trpc from '@trpc/server'; +import { TrpcAdapterContext, BufferToJSObjectTransformer } from '@h4ad/serverless-adapter/lib/frameworks/trpc'; +import { z } from 'zod'; + +type TrpcContext = TrpcAdapterContext; + +const appRouter = trpc + .router() + .transformer(new BufferToJSObjectTransformer()) + .mutation('sqs', { + input: z.object({ + Records: z.array(z.any()), + }), + async resolve({ input }) { + const event = input as SQSEvent; + + // Do whatever you want + // and you dont need to return nothing + }, + }); +``` + +:::tip + +You can see a working example [here](https://github.com/H4ad/serverless-adapter-examples/tree/master/src/trpc/aws/api-gateway-v2-and-sqs.entry.ts). + +::: + +## Default Context and Custom context + +By default, we provide you with a few methods to get information from the request and modify the response, +to learn more, take a look at [TrpcAdapterBaseContext](../../api/Frameworks/TrpcFramework/TrpcAdapterBaseContext). + +Also, you can pass new properties to the context by setting the `createContext` function inside the options, +like the code below: + +```typescript title="index.ts" +import * as trpc from '@trpc/server'; +import { ServerlessAdapter } from '@h4ad/serverless-adapter'; +import { TrpcFramework, TrpcAdapterContext, BufferToJSObjectTransformer, TrpcFrameworkOptions } from '@h4ad/serverless-adapter/lib/frameworks/trpc'; + +type CustomContext = { potato: boolean }; +type TrpcContext = TrpcAdapterContext; + +const appRouter = trpc + .router() + .transformer(new BufferToJSObjectTransformer()) + .mutation('add', { + async resolve({ ctx }) { + // get the request url + const requestUrl = ctx.getUrl(); + + // this will change the status code of the request to 204. + ctx.setStatus(204); + }, + }); + +const frameworkOptions: TrpcFrameworkOptions = { + // you can return a promise + createContext: () => Promise.resolve({ potato: true }), + // createContext: () => ({ potato: false }), +}; + +const framework = new TrpcFramework(frameworkOptions); + +export const handler = ServerlessAdapter.new(appRouter) + .setFramework(framework) + // continue to set the other options here. + //.setHandler(new DefaultHandler()) + //.setResolver(new PromiseResolver()) + //.addAdapter(new AlbAdapter()) + //.addAdapter(new SQSAdapter()) + //.addAdapter(new SNSAdapter()) + // after put all methods necessary, just call the build method. + .build(); +``` + +:::tip + +Is your application instance creation asynchronous? Look the [LazyFramework](./lazy) which helps you in asynchronous startup. + +::: diff --git a/www/docs/main/intro.mdx b/www/docs/main/intro.mdx index 9ec88b2a..79bbea9c 100644 --- a/www/docs/main/intro.mdx +++ b/www/docs/main/intro.mdx @@ -38,7 +38,6 @@ If you don't know what each thing means, see [Architecture](./architecture). Currently, we support these frameworks: -- [NestJS](https://nestjs.com/), see the [documentation about](./frameworks/nestjs) to know how to integrate. - [Express](https://expressjs.com/) by using ([ExpressFramework](./frameworks/express)) - [Fastify](https://www.fastify.io/) by using ([FastifyFramework](./frameworks/fastify)) - [Hapi](https://hapi.dev/) by using ([HapiFramework](./frameworks/hapi)) @@ -46,6 +45,8 @@ Currently, we support these frameworks: - Async Initialization by using ([LazyFramework](./frameworks/lazy)) - Use this framework to provide a way to create the instance of your app asynchronously. - With him, you can create an instance of Express or Fastify asynchronously, [see the docs](./frameworks/lazy). +- [NestJS](https://nestjs.com/), see the [documentation about](./frameworks/nestjs) to know how to integrate. +- [tRPC](https://trpc.io/), see the [documentation about](./frameworks/trpc) to know how to integrate. We support these event sources: diff --git a/www/sidebars.js b/www/sidebars.js index 31b1f7c0..c7e72b1e 100644 --- a/www/sidebars.js +++ b/www/sidebars.js @@ -77,7 +77,7 @@ const sidebars = { link: { type: 'generated-index', }, - collapsed: false, + collapsed: true, items: ['main/handlers/default', 'main/handlers/huawei'], }, { @@ -86,11 +86,11 @@ const sidebars = { link: { type: 'generated-index', }, - collapsed: false, + collapsed: true, items: [ - 'main/resolvers/promise', - 'main/resolvers/callback', 'main/resolvers/aws-context', + 'main/resolvers/callback', + 'main/resolvers/promise', ], }, { @@ -102,11 +102,12 @@ const sidebars = { collapsed: false, items: [ 'main/frameworks/express', - 'main/frameworks/nestjs', 'main/frameworks/fastify', 'main/frameworks/hapi', 'main/frameworks/koa', 'main/frameworks/lazy', + 'main/frameworks/nestjs', + 'main/frameworks/trpc', ], }, ],